--- title: Lembrete de consulta pelo WhatsApp com botões de confirmar e cancelar, na API v2 da Zavu description: Como enviar lembrete de consulta pelo WhatsApp 24 horas antes, com botões Confirmar e Cancelar, e receber a confirmação de consulta no seu webhook. date: 2026-10-19 author: Zavu Team locale: pt source: https://www.zavu.dev/pt/blog/recordatorios-de-citas-whatsapp tags: Tutorial, API v2, WhatsApp, Templates, Webhooks --- # Lembrete de consulta pelo WhatsApp com botões de confirmar e cancelar, na API v2 da Zavu ## TL;DR - **O WhatsApp é o canal com mais atrito**, cadastro na Meta e aprovação de templates, e o único em que o paciente responde com um botão. - **O lembrete é um template de utilidade** com dois botões, Confirmar e Cancelar. - **Um cron envia o lembrete 24 horas antes**, com um `idempotencyKey` por consulta para que uma segunda execução não envie nada. - **O toque chega no seu webhook como texto.** Você verifica a assinatura e usa `replyToMessageId` para achar a consulta certa. - **O toque abre a janela de 24 horas**, então você responde na mesma conversa com texto livre e `conversationId`. Paciente que não confirma vira horário vago. Num consultório em Curitiba, numa clínica odontológica em Belo Horizonte ou num salão em São Paulo, a cena é a mesma: alguém da recepção liga um por um na véspera, ou ninguém liga e o paciente não aparece. O **lembrete de consulta pelo WhatsApp** resolve isso quando o paciente responde com um toque e o seu sistema fica sabendo sozinho. Este tutorial monta esse fluxo inteiro sobre a [API v2 da Zavu](https://docs.zavu.dev/api-v2/overview). No final você terá: - Um template de WhatsApp de utilidade com dois botões: **Confirmar** e **Cancelar**. - Um cron em Node que envia o lembrete 24 horas antes de cada consulta, sem duplicar mesmo se rodar duas vezes. - Um webhook que recebe a confirmação de consulta, verifica a assinatura e atualiza a agenda. - Uma resposta automática na mesma conversa: "Combinado, até lá". Se você ainda não conhece a v2, o resumo está no [anúncio da Zavu 2.0](/pt/blog/zavu-2-0): não existe objeto sender, você envia a partir de um endereço com `from` ou responde dentro de uma conversa com `conversationId`. ## O que você precisa - **Uma conta Zavu e uma API key.** Você cria no dashboard, em **Criar → Desenvolvedores**. A mesma key serve para v1 e v2. Todas as chamadas usam `Authorization: Bearer`. - **Um canal de WhatsApp conectado.** É o canal com mais atrito: exige o cadastro na Meta (sua conta do WhatsApp Business) e a aprovação de cada template antes do uso. Em troca, é o único em que o paciente responde com um botão dentro da conversa. - **Um backend com Node 18 ou superior** (já vem com `fetch`) e uma URL HTTPS pública para o webhook. Se ainda não dá para passar pela Meta, há opções honestas, com menos interação: | Canal | O que pede | O que você perde | |-------|------------|------------------| | SMS de mão única | Nada: `POST /v2/channels` com `{"type": "sms_oneway"}` | O paciente não consegue responder. Sem botões. | | Email | Um domínio verificado | Sem botões na conversa; a confirmação vira um link no email. | | Telegram | Um token do @BotFather | O paciente precisa ter iniciado o seu bot antes. | O resto do tutorial usa WhatsApp, porque os botões são o ponto. ## 1. Conecte o WhatsApp em Canais WhatsApp, Instagram e Messenger se conectam pela Meta, no dashboard, não pela API. Abra **Criar → Canais → Conectar um canal**, escolha WhatsApp e conclua o cadastro da Meta com o número do consultório. Quando terminar, **ative** o canal na mesma tela: um canal conectado e desligado recusa envios com `403 channel_inactive`. Depois confirme o endereço pela API. Você vai precisar dele para enviar o template para revisão: ```bash curl https://api.zavu.dev/v2/channels \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" ``` ```json { "items": [ { "address": "+5511912345678", "type": "whatsapp", "active": true, "v1SenderId": "kd7..." } ], "nextCursor": null } ``` `+5511912345678` é o seu endereço de WhatsApp. Na v2 um número é nomeado assim, do jeito que o paciente vê. ## 2. Crie o template do lembrete com botões O lembrete sai quando o paciente não escreveu nas últimas 24 horas. Fora dessa janela o WhatsApp só permite **templates aprovados**, então o lembrete é um template. ```bash curl -X POST https://api.zavu.dev/v2/templates \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "lembrete_consulta", "language": "pt_BR", "whatsappCategory": "UTILITY", "body": "Olá, {{1}}. Lembramos da sua consulta na {{2}} em {{3}}. Você confirma sua presença?", "footer": "Responda tocando em um botão", "buttons": [ { "type": "quick_reply", "text": "Confirmar" }, { "type": "quick_reply", "text": "Cancelar" } ] }' ``` O que importa em cada campo: - **`whatsappCategory`**: `UTILITY`, porque é um aviso sobre algo que o paciente já agendou. As categorias são `UTILITY`, `MARKETING` e `AUTHENTICATION`; sem informar, a Zavu usa `UTILITY`. A Meta revisa a categoria na aprovação, então nada de promoção no texto. - **Variáveis posicionais**: `{{1}}`, `{{2}}`, `{{3}}`. No envio você preenche com `{"1": ..., "2": ..., "3": ...}`. Termine o corpo com texto, não com uma variável. - **`buttons`**: até 3, do tipo `quick_reply`, com rótulos de até 25 caracteres. Escolha rótulos que você não vai mudar: mais adiante você verá que o webhook os lê. A resposta é `201` com o template em `draft`. Guarde o `id` e envie para revisão, nomeando com `from` o número de WhatsApp cuja conta recebe o template: ```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": "+5511912345678", "category": "UTILITY", "variableExamples": { "var1": "Mariana", "var2": "Clínica Sorriso Pinheiros", "var3": "terça, 21/10, às 14h30" } }' ``` `variableExamples` são os valores de exemplo que a Meta vê na revisão; as chaves são `var1`, `var2`, `var3` para `{{1}}`, `{{2}}`, `{{3}}`. Sem eles, a Zavu manda exemplos genéricos, e um exemplo realista ajuda o revisor a entender o template. A resposta é `202` e o status passa a `pending`. Se `from` existir mas não tiver WhatsApp (por exemplo, é o seu endereço de email), você recebe `400 no_whatsapp` com `details.types` dizendo quais canais esse endereço tem. Para saber quando a Meta decidir, assine `template.status_changed` no webhook (passo 4) ou consulte `GET /v2/templates/TEMPLATE_ID` até `status` ser `approved`. Não agende envios antes disso. ## 3. Envie o lembrete 24 horas antes Com o template aprovado, um lembrete é um `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": "+5511912345678", "to": "+5511987654321", "channel": "whatsapp", "messageType": "template", "content": { "templateId": "TEMPLATE_ID", "templateVariables": { "1": "Mariana", "2": "Clínica Sorriso Pinheiros", "3": "terça, 21/10, às 14h30" } }, "idempotencyKey": "lembrete-consulta-4821" }' ``` ```json { "message": { "id": "jd7x2k3m4n5p6q7r8s9t0", "to": "+5511987654321", "from": "+5511912345678", "channel": "whatsapp", "direction": "outbound", "messageType": "template", "status": "queued" }, "routing": { "channel": "whatsapp", "from": "+5511912345678", "reason": "explicit", "estimatedCost": null } } ``` `routing.estimatedCost` é `null` no WhatsApp: ele informa o que o envio tira do seu saldo na Zavu, e hoje isso só acontece com SMS. Três decisões nessa chamada: - **`channel: "whatsapp"`**. Com um canal explícito, a Zavu não recorre a SMS se o WhatsApp falhar, a menos que você passe `fallbackEnabled: true`. Aqui isso é bom: um SMS não leva botões, e o paciente receberia uma pergunta que não consegue responder. - **`idempotencyKey` por consulta**. Se o cron rodar duas vezes, ou você tentar de novo depois de um timeout, a segunda chamada com a mesma chave não envia nada: responde `409` com `code: "conflict"` e a mensagem original em `details.message`. - **Guarde `message.id`** na consulta. É ele que diz, quando a resposta chegar, a qual consulta ela pertence. O cron, em Node com TypeScript. Roda a cada 10 minutos e pega as consultas que começam entre 24 horas e 24 horas e 10 minutos a partir de agora: ```ts import cron from "node-cron"; import { db } from "./db"; // seu acesso à agenda const ZAVU = "https://api.zavu.dev/v2"; const headers = { Authorization: "Bearer " + process.env.ZAVUDEV_API_KEY, "Content-Type": "application/json", }; async function enviarLembrete(consulta: Consulta) { const res = await fetch(ZAVU + "/messages", { method: "POST", headers, body: JSON.stringify({ from: process.env.WHATSAPP_FROM, // "+5511912345678" to: consulta.telefone, // E.164, com "+" channel: "whatsapp", messageType: "template", content: { templateId: process.env.TEMPLATE_ID, templateVariables: { "1": consulta.nome, "2": consulta.unidade, "3": formatarData(consulta.inicio, "America/Sao_Paulo"), }, }, idempotencyKey: "lembrete-consulta-" + consulta.id, }), }); const json = await res.json(); if (res.status === 202) return json.message; if (res.status === 409 && json.code === "conflict") return json.details.message; // já enviado throw new Error(json.code + ": " + json.message); } cron.schedule("*/10 * * * *", async () => { const desde = Date.now() + 24 * 3600_000; const consultas = await db.consultasSemLembrete(desde, desde + 10 * 60_000); for (const consulta of consultas) { try { const mensagem = await enviarLembrete(consulta); await db.marcarLembrada(consulta.id, mensagem.id, mensagem.conversationId ?? null); } catch (err) { console.error("Lembrete não enviado", consulta.id, err); } } }); ``` Guarde os horários em UTC e formate a data no fuso do paciente ao montar a variável. O Brasil tem mais de um fuso: um lembrete com o horário de Brasília para um paciente em Manaus marca a consulta uma hora errada. `message.conversationId` pode vir vazio no `202`: na primeira vez que você escreve para o paciente, a conversa é criada logo depois do envio ser aceito. Isso se resolve no passo 5. ## 4. Receba a confirmação de consulta no webhook A v2 tem um único webhook por projeto. Crie com os três eventos deste fluxo: ```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.br/webhooks/zavu", "events": ["message.inbound", "message.failed", "template.status_changed"] }' ``` A resposta `201` traz `webhook.secret` **uma única vez**. Guarde nas variáveis de ambiente; as leituras seguintes não devolvem o segredo, e `POST /v2/webhook/secret` o rotaciona. ### Como o botão chega Vale saber isto antes de escrever o handler. Quando o paciente toca **Confirmar** no template, o webhook recebe um `message.inbound` assim: ```json { "id": "evt_1760900000000_k2j4h1", "event": "message.inbound", "timestamp": 1760900000000, "projectId": "jx7xyz789ghi012", "from": "+5511987654321", "to": "+5511912345678", "type": "whatsapp", "data": { "messageId": "jx7744x3wckc9cwy0j9c8g6v0h7z7shs", "from": "+5511987654321", "to": "+5511912345678", "channel": "whatsapp", "messageType": "text", "text": "Confirmar", "content": { "replyToMessageId": "jd7x2k3m4n5p6q7r8s9t0", "replyToProviderMessageId": "wamid.HBgM...", "replyToMessageType": "template" } } } ``` Dois detalhes que o código precisa respeitar: - **O botão chega como texto.** `messageType` é `text` e `text` é o rótulo do botão. Não vem um identificador separado do botão, então o handler compara com os rótulos que você pôs no template. Por isso é melhor não mudá-los. - **`content.replyToMessageId`** é o id do lembrete que você guardou no passo 3. Com ele você encontra a consulta exata, mesmo que o paciente tenha duas na semana. Se não vier, use a próxima consulta pendente desse telefone (`data.from`). ### O handler, com verificação de assinatura Cada entrega traz `X-Zavu-Signature: t=,v2=`, em que `v2` é `HMAC_SHA256(secret, "{t}.{body}")` sobre o corpo **sem parse**. Verifique antes de mexer na agenda: ```ts import express from "express"; import crypto from "node:crypto"; const app = express(); function assinaturaValida(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; // mais 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 (!assinaturaValida(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(); // responda rápido; processe depois if (await db.eventoJaProcessado(evento.id)) return; if (evento.event !== "message.inbound") return; const { text, from, content } = evento.data; const resposta = (text ?? "").trim().toLowerCase(); if (resposta !== "confirmar" && resposta !== "cancelar") return; // outra mensagem: vai para a sua caixa const consulta = (content?.replyToMessageId && (await db.consultaPorLembrete(content.replyToMessageId))) || (await db.proximaConsultaPendente(from)); if (!consulta) return; await db.atualizarConsulta(consulta.id, resposta === "confirmar" ? "confirmada" : "cancelada"); await responder(consulta, resposta); }); ``` Uma entrega que não recebe `2xx` é reenviada, até 5 tentativas no total. Cada evento tem o seu `id`: use para não processar a mesma confirmação duas vezes. ## 5. Responda na mesma conversa O toque do paciente é uma mensagem recebida, e isso **abre a janela de atendimento de 24 horas**. Dentro dela você responde com texto livre, sem outro template. Na v2 você responde dentro da conversa com `conversationId`; o `to` vem da própria conversa: ```ts async function responder(consulta: Consulta, resposta: string) { let conversationId = consulta.conversationId; if (!conversationId) { const r = await fetch(ZAVU + "/messages/" + consulta.lembreteId, { headers }); conversationId = (await r.json()).message?.conversationId; } const texto = resposta === "confirmar" ? "Combinado, " + consulta.nome + ". Até " + formatarData(consulta.inicio, "America/Sao_Paulo") + "." : "Sua consulta foi cancelada. Se quiser outro horário, é só responder aqui."; await fetch(ZAVU + "/messages", { method: "POST", headers, body: JSON.stringify( conversationId ? { conversationId, channel: "whatsapp", text: texto } : { from: process.env.WHATSAPP_FROM, to: consulta.telefone, channel: "whatsapp", text: texto }, ), }); } ``` Quando o paciente responde, o lembrete já tem a sua conversa: por isso o código lê `conversationId` da mensagem que você guardou, com `GET /v2/messages/{id}`, se ele não veio no `202`. Se você passar `conversationId` junto com um `to` que não é o paciente dessa conversa, a API responde `400` em vez de abrir outra conversa. Se ele cancelar, a conversa fica aberta em **Conversas**, no dashboard, onde a recepção pode continuar e oferecer outro horário. ## Testando, e o que pode dar errado Teste com uma key em produção e o seu próprio celular como paciente. Uma key de teste sempre envia pelo número sandbox de WhatsApp da Zavu e ignora `from` e `conversationId`, então não serve para este fluxo com o seu template. Os erros da v2 são planos, `{code, message, details}`, e é o `code` que o seu código deve olhar: | Resposta | O que significa | O que fazer | |----------|-----------------|-------------| | `400 invalid_request` | Um campo falta ou está errado. | Veja `details.errors`; costuma ser um `to` sem `+`. | | `400 no_whatsapp` | Você enviou para revisão a partir de um endereço sem WhatsApp. | Use o endereço `whatsapp` de `GET /v2/channels`. | | `403 channel_inactive` | O canal está conectado, mas desligado. | Ative em **Canais**. | | `404 not_found` | O template ou o endereço não são deste projeto. | Confira `TEMPLATE_ID` e `from`. | | `409 conflict` | `idempotencyKey` repetido. | Já foi enviado: use `details.message`. | | `409 no_channel` | Nada do que você tem alcança esse destinatário. | `details.connect` diz o que conectar. | | `429 daily_limit_exceeded` | O teto diário do canal foi atingido. | Zera às 00:00 UTC. | Um `202` quer dizer aceito, não entregue. Se a Meta recusar a mensagem depois, chega um `message.failed` com `errorCode`. Cada código está explicado nos [erros da API v2](https://docs.zavu.dev/api-v2/errors). ## Indo para produção **Aprovação da Meta.** Todo template passa por revisão. Se for rejeitado, ajuste o texto e crie outro com um nome diferente (ou apague o anterior com `DELETE /v2/templates/{id}`), e envie de novo. Um template de utilidade deve ser um aviso sobre a consulta, sem ofertas. **A janela de 24 horas.** O lembrete vai como template porque normalmente a janela está fechada. A confirmação do paciente abre a janela, e a sua resposta vai como texto livre dentro dela. **Preços.** A Meta cobra os templates de utilidade pela tarifa do país do paciente, na sua conta do WhatsApp Business. Desde 1º de outubro de 2026 as respostas dentro da janela também são mensagens de serviço cobradas, com 1.000 grátis por mês por número, e sem forma de pagamento na conta a Meta para de entregá-las a partir da 1.001ª. Está explicado em [as 1.000 mensagens de serviço grátis e a forma de pagamento](/pt/blog/whatsapp-service-messages-payment-method). A Zavu não adiciona margem às tarifas da Meta. **Limites de envio.** WhatsApp, Telegram, Instagram e Messenger dividem um teto diário: 100 por dia numa conta não verificada, 250 no Free depois de verificada, e sem teto nos planos pagos. No Free eles também dividem 2.000 mensagens por mês, e a cota **conta os dois sentidos**: o lembrete e o "Confirmar" do paciente somam duas. Faça a conta com a sua agenda antes de escolher o plano; os detalhes estão nos [limites de envio](https://docs.zavu.dev/concepts/sending-limits). **LGPD e conteúdo.** Dado de saúde é dado pessoal sensível na LGPD. O lembrete deve dizer o necessário: data, hora e local. Nada de especialidade, exame ou diagnóstico no texto, e só para pacientes que aceitaram receber mensagens pelo WhatsApp. Se quiser ir além e deixar um agente cuidar das remarcações, o próximo passo é [criar um agente de suporte com IA](/pt/blog/agente-de-soporte-con-ia). E para automatizar outras respostas, veja [mensagem automática no WhatsApp Business](/pt/blog/mensagem-automatica-whatsapp-business).