--- title: Recordatorios de citas por WhatsApp con botones de confirmar y cancelar, con la API v2 de Zavu description: 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. date: 2026-10-19 author: Zavu Team locale: es source: https://www.zavu.dev/es/blog/recordatorios-de-citas-whatsapp tags: Tutorial, API v2, WhatsApp, Plantillas, Webhooks --- # Recordatorios de citas por WhatsApp con botones de confirmar y cancelar, con la API v2 de Zavu ## 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](https://docs.zavu.dev/api-v2/overview). 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](/es/blog/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. | | Email | 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: ```bash 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. ```bash 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: ```bash 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`: ```bash 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: ```ts 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: ```bash 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: ```ts 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: ```ts 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: | 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](https://docs.zavu.dev/api-v2/errors). ## 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](/es/blog/whatsapp-service-messages-payment-method). 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](https://docs.zavu.dev/concepts/sending-limits). **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](/es/blog/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](/es/blog/api-whatsapp-desarrolladores) cubre lo que cambia.