TutorialAPI v2WhatsAppPlantillasWebhooks

Recordatorios de citas por WhatsApp con botones de confirmar y cancelar, con la API v2 de Zavu

Cómo enviar recordatorios de citas por WhatsApp 24 horas antes con botones Confirmar y Cancelar, recibir la respuesta en tu webhook y actualizar la agenda.

Redactado por: Zavu TeamRevisado por: Jennifer Villalobos19 de octubre de 202612 min de lectura
Ver como Markdown

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 idempotencyKey por cita para que una segunda ejecución no envíe nada.
  • El toque llega a tu webhook como texto. Verificas la firma y con replyToMessageId encuentras 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:

CanalQué pideQué pierdes
SMS de una víaNada: POST /v2/channels con {"type": "sms_oneway"}El paciente no puede responder. Sin botones.
EmailUn dominio verificadoSin botones en el chat; confirmas con un enlace en el correo.
TelegramUn token de @BotFatherEl 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:

terminal
curl 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.

terminal
curl -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 son UTILITY, MARKETING y AUTHENTICATION; si no la indicas, Zavu usa UTILITY. 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 tipo quick_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:

terminal
curl -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:

terminal
curl -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 pases fallbackEnabled: true. Para este caso conviene: un SMS no lleva botones, y el paciente recibiría una pregunta que no puede contestar.
  • idempotencyKey por 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: responde 409 con code: "conflict" y el mensaje original en details.message.
  • Guarda message.id en 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:

TypeScript
import 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:

terminal
curl -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. messageType es text y text es 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.replyToMessageId es 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=,v2=, donde v2 es HMAC_SHA256(secret, "{t}.{body}") sobre el cuerpo sin parsear. Verifícala antes de tocar la agenda:

TypeScript
import 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:

TypeScript
async 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:

RespuestaQué significaQué hacer
400 invalid_requestUn campo falta o está mal.Mira details.errors; suele ser un to sin +.
400 no_whatsappEnviaste a revisión desde una dirección sin WhatsApp.Usa la dirección whatsapp de GET /v2/channels.
403 channel_inactiveEl canal está conectado pero apagado.Actívalo en Canales.
404 not_foundLa plantilla o la dirección no son de este proyecto.Revisa TEMPLATE_ID y from.
409 conflictidempotencyKey repetido.Ya se envió: usa details.message.
409 no_channelNada de lo que tienes llega a ese destinatario.details.connect dice qué conectar.
429 daily_limit_exceededSe 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.

Preguntas frecuentes

¿Necesito una plantilla aprobada para enviar recordatorios de citas por WhatsApp?
Sí. Un recordatorio sale cuando el paciente no te ha escrito en las últimas 24 horas, y fuera de esa ventana WhatsApp solo acepta plantillas aprobadas por Meta. Creas la plantilla con POST /v2/templates, la envías a revisión con POST /v2/templates/{id}/submit y esperas a que su estado sea approved.
¿Cómo sé qué botón tocó el paciente?
Por el texto del mensaje entrante. Cuando el paciente toca un botón de respuesta rápida de la plantilla, tu webhook recibe un message.inbound con messageType text y text igual a la etiqueta del botón, por ejemplo Confirmar. content.replyToMessageId apunta al recordatorio, así sabes de qué cita se trata.
¿Qué pasa si mi cron envía el mismo recordatorio dos veces?
No se envía dos veces si usas el mismo idempotencyKey. La segunda llamada a POST /v2/messages con una clave ya usada responde 409 conflict, no envía nada y devuelve el mensaje original en details.message. Usa una clave por cita, por ejemplo recordatorio-cita-4821, y trata ese 409 como enviado.
¿Puedo responder al paciente sin otra plantilla después de que confirme?
Sí. Al tocar el botón, el paciente te envía un mensaje, y eso abre la ventana de atención de 24 horas. Dentro de ella puedes responder con texto libre en la misma conversación pasando conversationId a POST /v2/messages. Esa respuesta es un mensaje de servicio para Meta.
¿Cuánto cuesta un recordatorio de cita por WhatsApp?
Meta cobra la plantilla de utilidad según la tarifa del país del paciente, directamente a tu cuenta de WhatsApp Business. Zavu no agrega margen a las tarifas de Meta. Los mensajes de WhatsApp cuentan para la cuota mensual de tu plan de Zavu: 2.000 al mes en Free, sin tope en los planes pagos.
¿Hay alternativa si no quiero conectar WhatsApp?
Sí, con menos interacción. El SMS de una vía se conecta con POST /v2/channels sin número propio ni Meta, pero no recibe respuestas ni tiene botones. El email necesita un dominio verificado y puede llevar un enlace de confirmación. Los botones de confirmar y cancelar dentro del chat son de WhatsApp.

Necesitas ayuda? Contáctanos o únete a nuestra comunidad en Discord para soporte.

Get started

Listo para empezar?

Comienza a construir gratis, o agenda una llamada para discutir tu caso de uso específico.

Recordatorios de citas por WhatsApp con API | Zavu Blog