TutorialAPI v2OTPAutenticação em dois fatoresWhatsAppSMS

Como enviar código de verificação por WhatsApp e SMS via API, com canal reserva

Tutorial para enviar código de verificação por WhatsApp e SMS com a API v2 da Zavu: gere e guarde o código no seu backend, envie e troque de canal se falhar.

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

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.failed e 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 fetch e curl.
  • Pelo menos dois canais, porque um canal reserva precisa de para onde ir. O que cada um exige:
CanalO que precisaAtrito
WhatsAppUma conta do WhatsApp Business conectada pelo cadastro da Meta, em Canais no dashboard, e um modelo de autenticação aprovadoO maior: cadastro na Meta e revisão do modelo
SMS de uma viaPOST /v2/channels {"type":"sms_oneway"}Nenhum para conectar. Sai de números compartilhados e não recebe respostas
E-mailUm domínio de e-mail verificado e depois POST /v2/channelsRegistros DNS, uma vez
SMS bidirecionalUm 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.
TypeScript
import { 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(${verificationId}:${code}).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; }

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:

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

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

TypeScript
const 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 = otp_${v.id}_${n}; 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: 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:

TypeScript
const { id, code } = await startVerification(user); await secretStore.set(otp:${id}, code, { ttlSeconds: 600 }); // ver passo 4 await advanceFrom(id, -1); // envia o passo 0

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:

terminal
curl -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=,v2=, 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.

TypeScript
import 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(${t}.${raw}).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, 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.

TypeScript
const 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(otp:${v.id}); 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"); }

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ódigoQuandoO que o seu código deve fazer
409 no_channelO endereço não tem conexão para o channel pedido. details.connect diz o que conectarPular para o próximo passo; corrigir a configuração
403 channel_inactiveA conexão existe, mas está desligadaPular; ativá-la
403 destination_not_verifiedUma conta que não verificou nada envia SMS só para os números que verificouPular; verificar a identidade ou adicionar um meio de pagamento
403 kyb_requiredSMS 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ãoPular para o próximo passo
422 sms_compliance_blockedA revisão de conteúdo recusou o texto do SMSCorrigir o texto; não repetir igual
429 rate_limit_exceededUm segundo SMS para o mesmo destinatário dentro do mesmo minutoPedir para esperar, ou usar outro canal
429 daily_limit_exceededO teto diário do grupo de canais, details.limit. Reinicia às 00:00 UTCPular: os outros grupos têm teto próprio
402 insufficient_balanceO SMS é pago com o saldo pré-pagoPular; recarregar o saldo
409 conflictO idempotencyKey já foi usado: nada foi reenviado, o original está em details.messageTratar como enviado
TypeScript
const 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.

Perguntas frequentes

A Zavu gera ou valida o código de verificação?
Não. A Zavu entrega a mensagem; o seu backend gera o código, guarda um hash com validade e limite de tentativas, e compara o que o usuário digita. Assim o código não depende de nenhum provedor de mensagens e você pode reenviar o mesmo código por outro canal sem ter duas fontes de verdade.
Como envio um código de verificação no WhatsApp com botão de copiar?
Crie um modelo de WhatsApp da categoria AUTHENTICATION com um botão otp do tipo COPY_CODE, envie para revisão da Meta e espere a aprovação. Depois envie com messageType template e o código em templateVariables na chave 1. A Zavu coloca esse valor no texto e no botão de copiar.
Preciso de um número próprio para enviar código por SMS no Brasil?
Não necessariamente. O SMS de uma via (sms_oneway) é conectado com uma única chamada, sem comprar número, e sai de números compartilhados; o usuário não consegue responder, o que para um código não faz diferença. O SMS bidirecional precisa de um número próprio, comprado em um plano pago.
Por que o segundo SMS falha com 429 rate_limit_exceeded?
A Zavu permite um SMS por destinatário por minuto, tanto no SMS bidirecional quanto no de uma via. Se o usuário toca em reenviar antes de 60 segundos desde o último SMS, ele esbarra nesse limite. Desative o botão por um minuto ou faça o reenvio sair por outro canal, que esse limite não cobre.
Como um fluxo de OTP deve trocar de canal?
Envie pelo primeiro canal e passe para o próximo quando o webhook informar message.failed ou quando nenhum message.delivered chegar dentro do tempo que você definir. Envie o mesmo código em cada tentativa, com um idempotencyKey novo por tentativa, para que uma mensagem atrasada continue válida e um retry HTTP não envie duas vezes.
A revisão de conteúdo de SMS da Zavu bloqueia códigos?
Uma mensagem com uma palavra como código ou verificação e um número de 4 a 8 dígitos é reconhecida como código de uso único e liberada sem revisão adicional. Mantenha o texto simples: o nome do app, o código e a validade, sem pedir para compartilhar ou encaminhar o código.

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.

Código de verificação por WhatsApp e SMS | Zavu Blog