TL;DR
- O código é do seu backend. A Zavu envia; você gera, guarda um hash e define validade e limite de tentativas.
- No WhatsApp o código vai num modelo de autenticação com botão de copiar, enviado só depois da aprovação da Meta.
- Informe o canal em cada passo. Sem
channel, a v2 escolhe um transporte uma única vez, e isso não é canal reserva. message.failede um timer passam o código para o próximo canal, pelo webhook do projeto.- A Zavu envia no máximo um SMS por destinatário por minuto, então um reenvio antes de 60 segundos recebe
429 rate_limit_exceeded.
Abrir conta, entrar de um celular novo, cadastrar uma chave Pix para saque: em uma fintech ou um marketplace, todos esses momentos dependem de um código de seis dígitos. Se o código não chega, o usuário não culpa o seu provedor de SMS, ele desiste do cadastro. Neste tutorial você vai enviar código de verificação por WhatsApp e SMS com a API v2 da Zavu e trocar de canal quando o primeiro falhar: a base de uma autenticação em dois fatores que não deixa ninguém para trás.
O que você terá no final:
- Um código gerado, guardado (com hash) e validado pelo seu próprio backend, com validade e limite de tentativas.
- Um modelo de autenticação do WhatsApp com botão de copiar código, enviado com
POST /v2/messages. - Um canal reserva por SMS de uma via ou e-mail, acionado pelo webhook do projeto.
- O mapa de erros que realmente aparecem num fluxo de verificação, e o que fazer com cada um.
Primeira vez com a v2? Leia Zavu 2.0 ou deixe aberta a visão geral da API v2 enquanto segue este post.
O que você precisa
- Uma conta Zavu e uma API key. A v2 usa a mesma key da v1, como Bearer token. Os SDKs chamam a v1 por enquanto, então aqui chamamos a v2 por HTTP com
fetchecurl. - Pelo menos dois canais, porque um canal reserva precisa de para onde ir. O que cada um exige:
| Canal | O que precisa | Atrito |
|---|---|---|
| Uma conta do WhatsApp Business conectada pelo cadastro da Meta, em Canais no dashboard, e um modelo de autenticação aprovado | O maior: cadastro na Meta e revisão do modelo | |
| SMS de uma via | POST /v2/channels {"type":"sms_oneway"} | Nenhum para conectar. Sai de números compartilhados e não recebe respostas |
Um domínio de e-mail verificado e depois POST /v2/channels | Registros DNS, uma vez | |
| SMS bidirecional | Um número próprio (POST /v2/numbers, plano pago) | Comprar o número |
Para um código, o SMS de uma via costuma bastar: ninguém precisa responder a um OTP. Se um dia a sua conta precisar verificar a empresa para usá-lo, a conexão e os envios respondem 403 kyb_required; hoje, por padrão, isso não é exigido.
1. Gere e guarde o código no seu backend
A Zavu envia mensagens. Ela não gera nem valida códigos, e não precisa: o código é um segredo da sua aplicação, e mantê-lo no seu banco é o que permite reenviá-lo por qualquer canal. O que segue é prática padrão, não um recurso da Zavu:
- Gere com uma fonte aleatória criptográfica, nunca com
Math.random. - Guarde um hash, nunca o código.
- Dê uma validade curta (10 minutos é o comum) e um máximo de tentativas.
- Compare em tempo constante.
TypeScriptimport { randomInt, createHmac, timingSafeEqual, randomUUID } from "node:crypto"; const PEPPER = process.env.OTP_PEPPER!; // segredo do servidor, fora do banco export function newCode(): string { return randomInt(0, 1_000_000).toString().padStart(6, "0"); } export function hashCode(verificationId: string, code: string): string { return createHmac("sha256", PEPPER).update().digest("hex"); } export async function startVerification(user: { id: string; phone: string; email?: string }) { const id = randomUUID(); const code = newCode(); await db.verifications.insert({ id, userId: user.id, phone: user.phone, // E.164 com "+", ex.: "+5511987654321" email: user.email ?? null, codeHash: hashCode(id, code), expiresAt: Date.now() + 10 * 60_000, attemptsLeft: 5, step: 0, // em que canal do plano estamos messageId: null, // a mensagem da Zavu do passo atual status: "pending", }); return { id, code }; } export async function checkCode(id: string, typed: string): Promise<boolean> { const v = await db.verifications.get(id); if (!v || v.status !== "pending" || Date.now() > v.expiresAt || v.attemptsLeft <= 0) return false; await db.verifications.update(id, { attemptsLeft: v.attemptsLeft - 1 }); const a = Buffer.from(hashCode(id, typed)); const b = Buffer.from(v.codeHash); const ok = a.length === b.length && timingSafeEqual(a, b); if (ok) await db.verifications.update(id, { status: "verified" }); return ok; }${verificationId}:${code}
db é o que você já usa. O banco guarda só o hash, e o telefone é dado pessoal: trate-o como manda a LGPD, com finalidade clara e prazo de retenção. O mesmo código é reutilizado em cada passo do canal reserva: se a primeira mensagem chegar atrasada ela continua válida, e o usuário nunca vê dois códigos diferentes. No passo 4 você vê onde guardá-lo até expirar.
2. Crie o modelo de autenticação do WhatsApp
O WhatsApp só deixa uma empresa iniciar a conversa com um modelo, e códigos vão na categoria AUTHENTICATION. Crie com POST /v2/templates:
terminalcurl -X POST https://api.zavu.dev/v2/templates \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "name": "codigo_acesso", "language": "pt_BR", "body": "Seu código de verificação é {{1}}.", "whatsappCategory": "AUTHENTICATION", "buttons": [{ "type": "otp", "otpType": "COPY_CODE", "text": "Copiar código" }], "addSecurityRecommendation": true, "codeExpirationMinutes": 10 }'
Num modelo de autenticação com botão OTP, o texto da mensagem é escrito pelo próprio WhatsApp; addSecurityRecommendation adiciona a linha de "não compartilhe este código" e codeExpirationMinutes o rodapé com a validade. Depois envie o modelo para a conta do WhatsApp Business do seu número, identificado pelo endereço:
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": "+5511987654321", "category": "AUTHENTICATION" }'
A resposta da Meta chega como evento template.status_changed. Não use o modelo antes da aprovação: um envio com modelo não aprovado falha.
3. Enviar código de verificação por WhatsApp e SMS com POST /v2/messages
Cada passo do plano é uma chamada. O código vai em templateVariables na chave "1"; a Zavu coloca esse valor no texto e no botão de copiar. O idempotencyKey é por tentativa, para que um retry HTTP nunca envie o código duas vezes.
TypeScriptconst ZAVU = "https://api.zavu.dev/v2"; type Step = "whatsapp" | "sms_oneway" | "email"; // A ordem é sua. const PLAN: Step[] = ["whatsapp", "sms_oneway", "email"]; function payload(step: Step, v: { id: string; phone: string; email: string | null }, code: string, n: number) { const idempotencyKey =; if (step === "whatsapp") { return { to: v.phone, from: process.env.ZAVU_WHATSAPP_NUMBER, // ex.: "+5511987654321" channel: "whatsapp", messageType: "template", content: { templateId: process.env.OTP_TEMPLATE_ID, templateVariables: { "1": code } }, idempotencyKey, }; } if (step === "sms_oneway") { return { to: v.phone, from: "sms_oneway", channel: "sms_oneway", text:otp_${v.id}_${n}Conta Fácil: seu código de verificação é${code}. Expira em 10 minutos., idempotencyKey, }; } return { to: v.email, channel: "email", subject: "Seu código de verificação da Conta Fácil", text:Seu código de verificação é${code}. Expira em 10 minutos., idempotencyKey, }; } export async function sendStep(v: any, code: string) { const res = await fetch(${ZAVU}/messages, { method: "POST", headers: { Authorization:Bearer${process.env.ZAVUDEV_API_KEY}, "Content-Type": "application/json" }, body: JSON.stringify(payload(PLAN[v.step], v, code, v.step)), }); return { status: res.status, json: await res.json() }; }
Um envio por WhatsApp aceito responde 202 e mostra como foi roteado:
JSON{ "message": { "id": "jd7x2k3m4n5p6q7r8s9t0", "to": "+5521998765432", "from": "+5511987654321", "channel": "whatsapp", "direction": "outbound", "messageType": "template", "status": "queued", "createdAt": "2026-10-19T15:04:05.000Z" }, "routing": { "channel": "whatsapp", "from": "+5511987654321", "reason": "explicit", "estimatedCost": null } }
Guarde message.id na verificação: é assim que o webhook a encontra. Começar uma verificação fica em três linhas:
TypeScriptconst { id, code } = await startVerification(user); await secretStore.set(, code, { ttlSeconds: 600 }); // ver passo 4 await advanceFrom(id, -1); // envia o passo 0otp:${id}
routing.reason é explicit porque o passo informa o channel. estimatedCost é null no WhatsApp e no e-mail, onde o preço não é conhecido no envio; no SMS é o que o envio descontou do seu saldo pré-pago.
Informe o canal em cada passo. Sem channel, a v2 escolhe o primeiro transporte ativo do endereço (para um telefone: WhatsApp, depois SMS, depois SMS de uma via). Isso não é canal reserva: decide uma vez, antes de qualquer entrega. A Zavu também tem um fallback automático de WhatsApp para SMS, mas ele só cobre um número que tenha os dois no mesmo endereço, e com channel explícito fica desligado a menos que você envie fallbackEnabled: true. Para um fluxo entre WhatsApp, SMS de uma via e e-mail, o canal reserva é seu.
4. Escute o webhook e passe para o próximo canal
A v2 tem um único webhook por projeto. Inscreva-o nos eventos de entrega:
terminalcurl -X PUT https://api.zavu.dev/v2/webhook \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.seuapp.com.br/webhooks/zavu", "events": ["message.delivered", "message.failed"] }'
O 201 devolve webhook.secret uma única vez; guarde. Cada entrega vem assinada em X-Zavu-Signature como t=, em que v2 é HMAC_SHA256(secret, "{t}.{body}") sobre o body bruto. Os eventos de mensagem trazem data.messageId, data.status, data.errorCode e data.errorMessage.
TypeScriptimport express from "express"; import { createHmac, timingSafeEqual } from "node:crypto"; const app = express(); function verify(raw: string, header: string | undefined): boolean { const parts = Object.fromEntries((header ?? "").split(",").map((p) => p.split("=", 2))); const t = Number(parts.t); if (!parts.v2 || !Number.isFinite(t) || Math.abs(Date.now() / 1000 - t) > 300) return false; const expected = createHmac("sha256", process.env.ZAVU_WEBHOOK_SECRET!).update().digest("hex"); return expected.length === parts.v2.length && timingSafeEqual(Buffer.from(expected), Buffer.from(parts.v2)); } app.post("/webhooks/zavu", express.raw({ type: "application/json" }), async (req, res) => { const raw = req.body.toString("utf8"); if (!verify(raw, req.header("X-Zavu-Signature"))) return res.sendStatus(401); const evt = JSON.parse(raw); res.sendStatus(200); // responda rápido: sem um 2xx, a entrega é reenviada const v = await db.verifications.findByMessageId(evt.data?.messageId); if (!v || v.status !== "pending") return; // já verificada, expirada ou entrega repetida if (evt.event === "message.delivered") { await db.verifications.update(v.id, { deliveredAt: Date.now() }); } else if (evt.event === "message.failed") { await advance(v,${t}.${raw}failed:${evt.data.errorCode ?? "unknown"}); } });
message.failed cobre as falhas claras. Não cobre uma mensagem que simplesmente nunca é entregue: um celular desligado, ou um SMS retido para revisão (fica em queued enquanto uma pessoa analisa). Para isso existe o tempo limite. Escolha um que faça sentido para os seus usuários; de 30 a 45 segundos é um bom começo.
TypeScriptconst TIMEOUT_MS = 30_000; async function advanceFrom(id: string, step: number) { const v = await db.verifications.get(id); await advance({ ...v, step }, "start"); } async function advance(v: any, reason: string) { const next = { ...v, step: v.step + 1 }; if (next.step >= PLAN.length || Date.now() > v.expiresAt) { await db.verifications.update(v.id, { status: "undeliverable", lastReason: reason }); return; } // O código não fica em texto puro no banco. Guarde-o num armazenamento de vida curta // (por exemplo Redis com TTL igual à validade), ou gere um novo aqui e atualize codeHash. const code = await secretStore.get(); if (PLAN[next.step] === "email" && !v.email) return advance(next, "sem e-mail"); const result = await sendStep(next, code); if (result.status === 202) { await db.verifications.update(v.id, { step: next.step, messageId: result.json.message.id }); scheduleTimeout(v.id, next.step, TIMEOUT_MS); } else if (isSkippable(result.json.code)) { await advance(next, result.json.code); // este canal não consegue levar: tente o próximo já } else { await db.verifications.update(v.id, { status: "error", lastReason: result.json.code }); } } // Quando o timer dispara: se o passo não mudou e nada foi entregue, avance. async function onTimeout(id: string, step: number) { const v = await db.verifications.get(id); if (v?.status === "pending" && v.step === step && !v.deliveredAt) await advance(v, "timeout"); }otp:${v.id}
5. Teste, e o que pode dar errado
Teste com uma key live e o seu próprio celular. Uma key de teste sempre envia pelo sandbox do WhatsApp: não consulta from, e qualquer channel que não seja WhatsApp responde 400. Para exercitar o canal reserva, use uma key live, envie para o seu número e faça o primeiro passo falhar de propósito, por exemplo com um modelo ainda pendente.
Todo erro da v2 é plano, {code, message, details}. Estes são os que aparecem num fluxo de verificação:
| Status e código | Quando | O que o seu código deve fazer |
|---|---|---|
409 no_channel | O endereço não tem conexão para o channel pedido. details.connect diz o que conectar | Pular para o próximo passo; corrigir a configuração |
403 channel_inactive | A conexão existe, mas está desligada | Pular; ativá-la |
403 destination_not_verified | Uma conta que não verificou nada envia SMS só para os números que verificou | Pular; verificar a identidade ou adicionar um meio de pagamento |
403 kyb_required | SMS de uma via fechado para a conta até a verificação da empresa. Não aparece enquanto o SMS de uma via não exigir verificação, que é o padrão | Pular para o próximo passo |
422 sms_compliance_blocked | A revisão de conteúdo recusou o texto do SMS | Corrigir o texto; não repetir igual |
429 rate_limit_exceeded | Um segundo SMS para o mesmo destinatário dentro do mesmo minuto | Pedir para esperar, ou usar outro canal |
429 daily_limit_exceeded | O teto diário do grupo de canais, details.limit. Reinicia às 00:00 UTC | Pular: os outros grupos têm teto próprio |
402 insufficient_balance | O SMS é pago com o saldo pré-pago | Pular; recarregar o saldo |
409 conflict | O idempotencyKey já foi usado: nada foi reenviado, o original está em details.message | Tratar como enviado |
TypeScriptconst SKIP = new Set([ "no_channel", "channel_inactive", "destination_not_verified", "kyb_required", "daily_limit_exceeded", "insufficient_balance", "ten_dlc_required", ]); const isSkippable = (code: string) => SKIP.has(code);
ten_dlc_required está na lista por um caso específico: SMS para números dos Estados Unidos e do Canadá (+1) exige uma campanha 10DLC aprovada. Se você tem usuários com número +1, esse passo é pulado e o código sai por WhatsApp ou e-mail. Mais em o que é A2P 10DLC.
Três pontos merecem mais atenção.
O limite de um SMS por minuto é uma armadilha para o reenvio. A Zavu envia no máximo um SMS por destinatário por minuto, no SMS bidirecional e no de uma via. Um botão de "reenviar código" que dispara um segundo SMS antes de 60 segundos recebe 429 rate_limit_exceeded. Desative o botão por um minuto, ou faça o reenvio sair por outro canal.
Escreva o SMS para ser lido como código. Todo SMS é revisado antes do envio. Um texto com uma palavra como "código" ou "verificação" e um número de 4 a 8 dígitos é reconhecido como código de uso único e passa na hora. Mantenha o texto simples: nome do app, código e validade. Não peça para compartilhar, encaminhar ou responder com o código: é a linguagem do phishing.
Não coloque links. Links em SMS precisam estar verificados antes do envio, e encurtadores de URL são sempre bloqueados. Um código de verificação não precisa de link.
6. Em produção
Tetos diários. Os canais dividem orçamentos, e cada orçamento tem seu teto. Uma conta que não verificou nada envia 25 SMS e 100 mensagens conversacionais (WhatsApp, Telegram, Instagram, Messenger) por dia; o SMS de uma via conta dentro dos 25. Verificar a conta (identidade, um meio de pagamento salvo, um depósito ou um plano pago) sobe o SMS para 200 por dia, e a verificação de identidade ou da empresa sobe para 10.000. O conversacional não tem teto diário nos planos pagos. O e-mail segue a cota do plano: 100 por dia e 3.000 por mês no Free. Como os orçamentos são separados, um canal reserva que passa do WhatsApp para o SMS também muda de teto. Detalhes em limites de envio.
Custos. A Meta cobra as mensagens de WhatsApp na sua conta do WhatsApp Business, e os modelos de autenticação são cobrados por mensagem conforme o país do destinatário. No Free, as mensagens de WhatsApp também contam dentro das 2.000 por mês que os canais de apps dividem, nas duas direções. O SMS é descontado por mensagem do seu saldo na Zavu, e routing.estimatedCost mostra quanto cada envio levou.
Proteja o endpoint que envia códigos. Todo endpoint que envia SMS atrai SMS pumping: bots que pedem códigos para números de tarifa premium para que outra pessoa receba a tarifa de terminação. A Zavu revisa os SMS de saída, e mesmo assim vale cuidar do seu lado:
- Limite os pedidos de código por usuário, por número e por IP.
- Permita só os países onde você opera: se o seu app atende o Brasil, recuse o resto.
- Coloque um CAPTCHA ou outro desafio antes do formulário.
- Limite quantos códigos podem ser pedidos por verificação e por dia.
- Crie um alerta para um salto repentino de pedidos para um país novo.
Expire e limpe. Marque as verificações como expiradas no fim da janela, apague o código do armazenamento temporário e nunca o escreva em logs.
O fluxo completo, numa linha: faça o hash do código no seu backend, envie com POST /v2/messages pelo canal que escolher, e deixe que message.failed e um timer passem para o próximo. Para melhorar a taxa de entrega, leia otimização de entrega de mensagens; como referência, erros da API v2 e o webhook da v2.