--- title: Como enviar código de verificação por WhatsApp e SMS via API, com canal reserva description: 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. date: 2026-10-19 author: Zavu Team locale: pt source: https://www.zavu.dev/pt/blog/otp-verification-codes-api tags: Tutorial, API v2, OTP, Autenticação em dois fatores, WhatsApp, SMS --- # Como enviar código de verificação por WhatsApp e SMS via API, com canal reserva ## 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](/pt/blog/zavu-2-0) ou deixe aberta a [visão geral da API v2](https://docs.zavu.dev/api-v2/overview) 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: | Canal | O que precisa | Atrito | |-------|---------------|--------| | WhatsApp | 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 | | E-mail | 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. ```ts 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 { 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`: ```bash 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: ```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": "+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. ```ts 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: ```ts 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: ```bash 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`. ```ts 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. ```ts 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ó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 | ```ts 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](/pt/blog/what-is-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](https://docs.zavu.dev/concepts/sending-limits). **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](/pt/blog/message-delivery-optimization); como referência, [erros da API v2](https://docs.zavu.dev/api-v2/errors) e [o webhook da v2](https://docs.zavu.dev/api-v2/webhook).