TutorialAPI v2WhatsAppTemplatesWebhooks

Lembrete de consulta pelo WhatsApp com botões de confirmar e cancelar, na API v2 da Zavu

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.

Escrito por: Zavu TeamRevisado por: Jennifer Villalobos19 de outubro de 202612 min de leitura
Ver como Markdown

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. 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: 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:

CanalO que pedeO que você perde
SMS de mão únicaNada: POST /v2/channels com {"type": "sms_oneway"}O paciente não consegue responder. Sem botões.
EmailUm domínio verificadoSem botões na conversa; a confirmação vira um link no email.
TelegramUm token do @BotFatherO 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:

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

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

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": "+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:

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

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

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.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:

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

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

RespostaO que significaO que fazer
400 invalid_requestUm campo falta ou está errado.Veja details.errors; costuma ser um to sem +.
400 no_whatsappVocê enviou para revisão a partir de um endereço sem WhatsApp.Use o endereço whatsapp de GET /v2/channels.
403 channel_inactiveO canal está conectado, mas desligado.Ative em Canais.
404 not_foundO template ou o endereço não são deste projeto.Confira TEMPLATE_ID e from.
409 conflictidempotencyKey repetido.Já foi enviado: use details.message.
409 no_channelNada do que você tem alcança esse destinatário.details.connect diz o que conectar.
429 daily_limit_exceededO 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.

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

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. E para automatizar outras respostas, veja mensagem automática no WhatsApp Business.

Perguntas frequentes

Preciso de um template aprovado para enviar lembrete de consulta pelo WhatsApp?
Sim. O lembrete sai quando o paciente não escreveu para você nas últimas 24 horas, e fora dessa janela o WhatsApp só aceita templates aprovados pela Meta. Você cria o template com POST /v2/templates, envia para revisão com POST /v2/templates/{id}/submit e espera o status ficar approved.
Como eu sei qual botão o paciente tocou?
Pelo texto da mensagem recebida. Quando o paciente toca um botão de resposta rápida do template, seu webhook recebe um message.inbound com messageType text e text igual ao rótulo do botão, por exemplo Confirmar. content.replyToMessageId aponta para o lembrete, então você sabe de qual consulta se trata.
O que acontece se o meu cron enviar o mesmo lembrete duas vezes?
Nada é enviado duas vezes se você usar o mesmo idempotencyKey. A segunda chamada a POST /v2/messages com uma chave já usada responde 409 conflict, não envia nada e devolve a mensagem original em details.message. Use uma chave por consulta, como lembrete-consulta-4821, e trate esse 409 como enviado.
Posso responder ao paciente sem outro template depois que ele confirmar?
Sim. Ao tocar o botão, o paciente envia uma mensagem para você, e isso abre a janela de atendimento de 24 horas. Dentro dela você responde com texto livre na mesma conversa, passando conversationId para POST /v2/messages. Para a Meta, essa resposta é uma mensagem de serviço.
Quanto custa um lembrete de consulta pelo WhatsApp?
A Meta cobra o template de utilidade pela tarifa do país do paciente, direto na sua conta do WhatsApp Business. A Zavu não adiciona margem às tarifas da Meta. As mensagens de WhatsApp contam na cota mensal do seu plano Zavu: 2.000 por mês no Free, sem limite nos planos pagos.
Existe alternativa se eu não quiser conectar o WhatsApp?
Sim, com menos interação. O SMS de mão única se conecta com POST /v2/channels sem número próprio nem Meta, mas não recebe respostas nem tem botões. O email precisa de um domínio verificado e pode levar um link de confirmação. Botões de confirmar e cancelar dentro da conversa são do WhatsApp.

Precisa de ajuda? Contate-nos ou junte-se à nossa comunidade Discord para suporte.

Get started

Pronto para começar?

Comece a construir gratuitamente ou agende uma chamada para discutir seu caso de uso específico.

Lembrete de consulta pelo WhatsApp via API | Zavu Blog