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
idempotencyKeypor 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
replyToMessageIdpara 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:
| 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. |
| 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:
terminalcurl 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.
terminalcurl -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ãoUTILITY,MARKETINGeAUTHENTICATION; sem informar, a Zavu usaUTILITY. 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 tipoquick_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:
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": "+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:
terminalcurl -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ê passefallbackEnabled: true. Aqui isso é bom: um SMS não leva botões, e o paciente receberia uma pergunta que não consegue responder.idempotencyKeypor 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: responde409comcode: "conflict"e a mensagem original emdetails.message.- Guarde
message.idna 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:
TypeScriptimport 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:
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.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étextetexté 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=, em que v2 é HMAC_SHA256(secret, "{t}.{body}") sobre o corpo sem parse. Verifique antes de mexer na agenda:
TypeScriptimport 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:
TypeScriptasync 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.
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.