TL;DR
- WhatsApp es el canal con más fricción, registro de Meta y aprobación de plantillas, y el único donde el paciente responde con un botón.
- El recordatorio es una plantilla de utilidad con dos botones, Confirmar y Cancelar.
- Un cron lo envía 24 horas antes, con un
idempotencyKeypor cita para que una segunda ejecución no envíe nada. - El toque llega a tu webhook como texto. Verificas la firma y con
replyToMessageIdencuentras la cita exacta. - El toque abre la ventana de 24 horas, así que respondes en la misma conversación con texto libre y
conversationId.
Una cita que no se confirma es una hora vacía en la agenda. En una clínica dental de Guadalajara, una barbería de Providencia o un centro de estética en Medellín pasa lo mismo: alguien llama uno por uno el día anterior, o nadie llama y el paciente simplemente no llega. Los recordatorios de citas por WhatsApp resuelven eso si el paciente puede responder con un toque y tu sistema se entera solo.
Este tutorial arma ese flujo completo sobre la API v2 de Zavu. Al final vas a tener:
- Una plantilla de WhatsApp de utilidad con dos botones: Confirmar y Cancelar.
- Un cron en Node que envía el recordatorio 24 horas antes de cada cita, sin duplicados aunque se ejecute dos veces.
- Un webhook que recibe el botón que tocó el paciente, verifica la firma y actualiza la cita.
- Una respuesta automática en la misma conversación: "Listo, te esperamos".
Si todavía no conoces la v2, el resumen está en el anuncio de Zavu 2.0: no hay objeto sender, envías desde una dirección con from o respondes dentro de una conversación con conversationId.
Lo que necesitas
- Una cuenta de Zavu y una API key. La creas en el dashboard, en Construir → Desarrolladores. Es la misma key para v1 y v2. Todas las llamadas usan
Authorization: Bearer. - Un canal de WhatsApp conectado. Es el canal con más fricción: pide pasar por el registro de Meta (tu cuenta de WhatsApp Business) y que Meta apruebe cada plantilla antes de usarla. A cambio, es el único donde el paciente responde con un botón dentro del chat.
- Un backend con Node 18 o superior (trae
fetch) y una URL HTTPS pública para el webhook.
Si no puedes pasar por Meta todavía, hay opciones honestas, con menos interacción:
| Canal | Qué pide | Qué pierdes |
|---|---|---|
| SMS de una vía | Nada: POST /v2/channels con {"type": "sms_oneway"} | El paciente no puede responder. Sin botones. |
| Un dominio verificado | Sin botones en el chat; confirmas con un enlace en el correo. | |
| Telegram | Un token de @BotFather | El paciente tiene que haber iniciado tu bot antes. |
El resto del tutorial usa WhatsApp, porque los botones son el punto.
1. Conecta WhatsApp desde Canales
WhatsApp, Instagram y Messenger se conectan a través de Meta, desde el dashboard, no por API. Entra a Construir → Canales → Conectar un canal, elige WhatsApp y completa el registro de Meta con el número de la clínica. Cuando termine, actívalo en la misma pantalla: un canal conectado pero apagado rechaza los envíos con 403 channel_inactive.
Después confirma la dirección desde la API. La necesitas para enviar la plantilla a revisión:
terminalcurl https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY"
JSON{ "items": [ { "address": "+525512345678", "type": "whatsapp", "active": true, "v1SenderId": "kd7..." } ], "nextCursor": null }
+525512345678 es tu dirección de WhatsApp. En v2 un número se nombra así, como lo ve el paciente.
2. Crea la plantilla del recordatorio con botones
Un recordatorio se envía cuando el paciente no te ha escrito en las últimas 24 horas. Fuera de esa ventana WhatsApp solo deja enviar plantillas aprobadas, así que el recordatorio es una plantilla.
terminalcurl -X POST https://api.zavu.dev/v2/templates \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "recordatorio_cita", "language": "es_MX", "whatsappCategory": "UTILITY", "body": "Hola {{1}}, te recordamos tu cita en {{2}} el {{3}}. ¿Nos confirmas tu asistencia?", "footer": "Responde con un botón", "buttons": [ { "type": "quick_reply", "text": "Confirmar" }, { "type": "quick_reply", "text": "Cancelar" } ] }'
Lo que importa de cada campo:
whatsappCategory:UTILITY, porque es un aviso sobre algo que el paciente ya agendó. Las categorías sonUTILITY,MARKETINGyAUTHENTICATION; si no la indicas, Zavu usaUTILITY. Meta revisa la categoría al aprobar, así que no metas promociones en el texto.- Variables posicionales:
{{1}},{{2}},{{3}}. Al enviar las llenas con un objeto{"1": ..., "2": ..., "3": ...}. Termina el cuerpo con texto, no con una variable. buttons: hasta 3, de tipoquick_reply, con etiquetas de hasta 25 caracteres. Elige etiquetas que no vayas a cambiar: más abajo vas a ver que tu webhook las lee.
La respuesta es 201 con la plantilla en estado draft. Guarda su id y envíala a revisión, nombrando con from el número de WhatsApp cuya cuenta la recibe:
terminalcurl -X POST https://api.zavu.dev/v2/templates/TEMPLATE_ID/submit \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "+525512345678", "category": "UTILITY", "variableExamples": { "var1": "Ana", "var2": "Clínica Sonrisa Roma", "var3": "martes 21 a las 10:30" } }'
variableExamples son los valores de ejemplo que Meta ve al revisar; las claves son var1, var2, var3 para {{1}}, {{2}}, {{3}}. Si los omites, Zavu manda ejemplos genéricos, y un ejemplo realista ayuda a que el revisor entienda la plantilla.
La respuesta es 202 y el estado pasa a pending. Si from existe pero no tiene WhatsApp (por ejemplo, es tu dirección de email), recibes 400 no_whatsapp con details.types diciendo qué canales tiene esa dirección.
Para saber cuándo Meta decide, suscribe tu webhook a template.status_changed (paso 4) o consulta GET /v2/templates/TEMPLATE_ID hasta que status sea approved. No programes envíos antes de eso.
3. Envía el recordatorio 24 horas antes
Con la plantilla aprobada, un recordatorio es un POST /v2/messages:
terminalcurl -X POST https://api.zavu.dev/v2/messages \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "from": "+525512345678", "to": "+525587654321", "channel": "whatsapp", "messageType": "template", "content": { "templateId": "TEMPLATE_ID", "templateVariables": { "1": "Ana", "2": "Clínica Sonrisa Roma", "3": "martes 21 a las 10:30" } }, "idempotencyKey": "recordatorio-cita-4821" }'
JSON{ "message": { "id": "jd7x2k3m4n5p6q7r8s9t0", "to": "+525587654321", "from": "+525512345678", "channel": "whatsapp", "direction": "outbound", "messageType": "template", "status": "queued" }, "routing": { "channel": "whatsapp", "from": "+525512345678", "reason": "explicit", "estimatedCost": null } }
routing.estimatedCost es null en WhatsApp: reporta lo que el envío toma de tu saldo en Zavu, y hoy eso solo ocurre con SMS. Tres decisiones en esa llamada:
channel: "whatsapp". Con un canal explícito, Zavu no recurre a SMS si WhatsApp falla, a menos que pasesfallbackEnabled: true. Para este caso conviene: un SMS no lleva botones, y el paciente recibiría una pregunta que no puede contestar.idempotencyKeypor cita. Si tu cron corre dos veces, o reintentas después de un timeout, la segunda llamada con la misma clave no envía nada: responde409concode: "conflict"y el mensaje original endetails.message.- Guarda
message.iden la cita. Es lo que te permite saber, cuando llegue la respuesta, a qué cita corresponde.
El cron, en Node con TypeScript. Corre cada 10 minutos y toma las citas que empiezan entre 24 horas y 24 horas y 10 minutos desde ahora:
TypeScriptimport cron from "node-cron"; import { db } from "./db"; // tu acceso a la agenda const ZAVU = "https://api.zavu.dev/v2"; const headers = { Authorization: "Bearer " + process.env.ZAVUDEV_API_KEY, "Content-Type": "application/json", }; async function sendReminder(cita: Cita) { const res = await fetch(ZAVU + "/messages", { method: "POST", headers, body: JSON.stringify({ from: process.env.WHATSAPP_FROM, // "+525512345678" to: cita.telefono, // E.164, con "+" channel: "whatsapp", messageType: "template", content: { templateId: process.env.TEMPLATE_ID, templateVariables: { "1": cita.nombre, "2": cita.sucursal, "3": formatearFecha(cita.inicio, "America/Mexico_City"), }, }, idempotencyKey: "recordatorio-cita-" + cita.id, }), }); const json = await res.json(); if (res.status === 202) return json.message; if (res.status === 409 && json.code === "conflict") return json.details.message; // ya enviado throw new Error(json.code + ": " + json.message); } cron.schedule("/10 ", async () => { const desde = Date.now() + 24 3600_000; const citas = await db.citasSinRecordatorio(desde, desde + 10 * 60_000); for (const cita of citas) { try { const mensaje = await sendReminder(cita); await db.marcarRecordada(cita.id, mensaje.id, mensaje.conversationId ?? null); } catch (err) { console.error("Recordatorio no enviado", cita.id, err); } } });
Guarda las horas de las citas en UTC y formatea la fecha en la zona del paciente al armar la variable. Un recordatorio que dice "10:30" a un paciente en Santiago cuando la cita es a las 10:30 de Ciudad de México es peor que no enviar nada.
message.conversationId puede venir vacío en el 202: si es la primera vez que le escribes a ese paciente, la conversación se crea justo después de aceptar el envío. Lo resolvemos en el paso 5.
4. Recibe las confirmaciones de los recordatorios de citas por WhatsApp
v2 tiene un solo webhook por proyecto. Créalo con los tres eventos que usa este flujo:
terminalcurl -X PUT https://api.zavu.dev/v2/webhook \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://clinica.example.com/webhooks/zavu", "events": ["message.inbound", "message.failed", "template.status_changed"] }'
La respuesta 201 trae webhook.secret una sola vez. Guárdalo en tus variables de entorno; las lecturas siguientes no lo devuelven, y POST /v2/webhook/secret lo rota.
Cómo llega el botón
Esto es lo que conviene saber antes de escribir el handler. Cuando el paciente toca Confirmar en la plantilla, tu webhook recibe un message.inbound así:
JSON{ "id": "evt_1760900000000_k2j4h1", "event": "message.inbound", "timestamp": 1760900000000, "projectId": "jx7xyz789ghi012", "from": "+525587654321", "to": "+525512345678", "type": "whatsapp", "data": { "messageId": "jx7744x3wckc9cwy0j9c8g6v0h7z7shs", "from": "+525587654321", "to": "+525512345678", "channel": "whatsapp", "messageType": "text", "text": "Confirmar", "content": { "replyToMessageId": "jd7x2k3m4n5p6q7r8s9t0", "replyToProviderMessageId": "wamid.HBgM...", "replyToMessageType": "template" } } }
Dos detalles que el código tiene que respetar:
- El botón llega como texto.
messageTypeestextytextes la etiqueta del botón. No viene un identificador del botón aparte, así que tu handler compara con las etiquetas que pusiste en la plantilla. Por eso conviene no cambiarlas. content.replyToMessageIdes el id del recordatorio que guardaste en el paso 3. Con eso encuentras la cita exacta, aunque el paciente tenga dos citas esa semana. Si no viniera, cae a la próxima cita pendiente de ese teléfono (data.from).
El handler, con verificación de firma
Cada entrega trae X-Zavu-Signature: t=, donde v2 es HMAC_SHA256(secret, "{t}.{body}") sobre el cuerpo sin parsear. Verifícala antes de tocar la agenda:
TypeScriptimport express from "express"; import crypto from "node:crypto"; const app = express(); function firmaValida(raw: string, header: string | undefined, secret: string) { if (!header) return false; const partes = Object.fromEntries(header.split(",").map((p) => p.split("="))); const t = Number(partes.t); if (!t || !partes.v2) return false; if (Math.abs(Date.now() / 1000 - t) > 300) return false; // más de 5 minutos const esperada = crypto.createHmac("sha256", secret).update(t + "." + raw).digest("hex"); return ( esperada.length === partes.v2.length && crypto.timingSafeEqual(Buffer.from(esperada), Buffer.from(partes.v2)) ); } app.post("/webhooks/zavu", express.raw({ type: "application/json" }), async (req, res) => { const raw = req.body.toString("utf8"); if (!firmaValida(raw, req.header("X-Zavu-Signature"), process.env.ZAVU_WEBHOOK_SECRET!)) { return res.status(401).end(); } const evento = JSON.parse(raw); res.status(200).end(); // responde rápido; procesa después if (await db.eventoYaProcesado(evento.id)) return; if (evento.event !== "message.inbound") return; const { text, from, content } = evento.data; const respuesta = (text ?? "").trim().toLowerCase(); if (respuesta !== "confirmar" && respuesta !== "cancelar") return; // otro mensaje: a tu bandeja const cita = (content?.replyToMessageId && (await db.citaPorRecordatorio(content.replyToMessageId))) || (await db.proximaCitaPendiente(from)); if (!cita) return; await db.actualizarCita(cita.id, respuesta === "confirmar" ? "confirmada" : "cancelada"); await responder(cita, respuesta); });
Una entrega que no recibe un 2xx se reintenta, hasta 5 intentos en total. Cada evento trae su propio id: úsalo para no procesar dos veces la misma confirmación.
5. Responde en la misma conversación
El toque del paciente es un mensaje entrante, y eso abre la ventana de atención de 24 horas. Dentro de ella puedes contestar con texto libre, sin otra plantilla. En v2 respondes dentro del hilo con conversationId; to se toma de la conversación:
TypeScriptasync function responder(cita: Cita, respuesta: string) { let conversationId = cita.conversationId; if (!conversationId) { const r = await fetch(ZAVU + "/messages/" + cita.recordatorioId, { headers }); conversationId = (await r.json()).message?.conversationId; } const texto = respuesta === "confirmar" ? "Listo, " + cita.nombre + ". Te esperamos el " + formatearFecha(cita.inicio, "America/Mexico_City") + "." : "Cancelamos tu cita. Si quieres otra fecha, escríbenos por aquí."; await fetch(ZAVU + "/messages", { method: "POST", headers, body: JSON.stringify( conversationId ? { conversationId, channel: "whatsapp", text: texto } : { from: process.env.WHATSAPP_FROM, to: cita.telefono, channel: "whatsapp", text: texto }, ), }); }
Para cuando el paciente responde, el recordatorio ya tiene su conversación: por eso el código lee conversationId del mensaje que guardaste, con GET /v2/messages/{id}, si no lo tenías desde el 202. Si pasas conversationId y además un to que no es el paciente de esa conversación, la API responde 400 en lugar de abrir otro hilo.
Si cancela, la conversación queda abierta en Conversaciones del dashboard, donde alguien de recepción puede seguir y proponer otra hora.
Probarlo y qué puede fallar
Prueba con una key en vivo y tu propio celular como paciente. Una key de prueba siempre envía desde el número sandbox de WhatsApp de Zavu e ignora from y conversationId, así que no sirve para este flujo con tu plantilla.
Los errores de v2 son planos, {code, message, details}, y el code es lo que tu código debe mirar:
| Respuesta | Qué significa | Qué hacer |
|---|---|---|
400 invalid_request | Un campo falta o está mal. | Mira details.errors; suele ser un to sin +. |
400 no_whatsapp | Enviaste a revisión desde una dirección sin WhatsApp. | Usa la dirección whatsapp de GET /v2/channels. |
403 channel_inactive | El canal está conectado pero apagado. | Actívalo en Canales. |
404 not_found | La plantilla o la dirección no son de este proyecto. | Revisa TEMPLATE_ID y from. |
409 conflict | idempotencyKey repetido. | Ya se envió: usa details.message. |
409 no_channel | Nada de lo que tienes llega a ese destinatario. | details.connect dice qué conectar. |
429 daily_limit_exceeded | Se alcanzó el techo diario del canal. | Se reinicia a las 00:00 UTC. |
Un 202 significa aceptado, no entregado. Si Meta rechaza el mensaje después, llega un message.failed con errorCode. El detalle de cada código está en los errores de la API v2.
Pasar a producción
Aprobación de Meta. Cada plantilla pasa por revisión. Si la rechazan, ajusta el texto y crea otra con un nombre distinto (o borra la anterior con DELETE /v2/templates/{id}), y envíala de nuevo. Una plantilla de utilidad debe ser un aviso sobre la cita, sin ofertas.
La ventana de 24 horas. El recordatorio va como plantilla porque normalmente la ventana está cerrada. La confirmación del paciente la abre, y tu respuesta va como texto libre dentro de ella.
Precios. Meta cobra las plantillas de utilidad según la tarifa del país del paciente, a tu cuenta de WhatsApp Business. Desde el 1 de octubre de 2026 las respuestas dentro de la ventana también son mensajes de servicio con costo, con 1.000 gratis al mes por número, y sin método de pago en la cuenta Meta deja de entregarlas desde el 1.001. Está explicado en los 1.000 mensajes de servicio gratis y el método de pago. Zavu no agrega margen a las tarifas de Meta.
Límites de envío. WhatsApp, Telegram, Instagram y Messenger comparten un techo diario: 100 al día en una cuenta sin verificar, 250 en Free una vez verificada, y sin techo en los planes pagos. En Free también comparten 2.000 mensajes al mes, y la cuota cuenta los dos sentidos: el recordatorio y el "Confirmar" del paciente suman dos. Haz la cuenta con tu agenda antes de elegir plan; el detalle está en los límites de envío.
Contenido. Que el paciente haya aceptado recibir mensajes por WhatsApp, y que el recordatorio diga lo justo: fecha, hora y lugar. Sin diagnósticos ni tratamientos en el texto.
Si quieres ir más lejos y que un agente atienda las reprogramaciones, el siguiente paso es crear un agente de soporte con IA. Y si vienes de la API de WhatsApp de Meta directo, la guía de la API de WhatsApp para desarrolladores cubre lo que cambia.