TL;DR
- El código es de tu backend. Zavu lo envía; tú lo generas, guardas un hash y le pones vencimiento y límite de intentos.
- En WhatsApp el código va en una plantilla de autenticación con botón de copiar, y solo se envía cuando Meta la aprueba.
- Nombra el canal en cada paso. Sin
channel, v2 elige un transporte una sola vez, y eso no es un respaldo. message.failedy un temporizador pasan el código al siguiente canal, a través del webhook del proyecto.- Zavu envía como máximo un SMS por destinatario por minuto, así que un reenvío antes de 60 segundos recibe
429 rate_limit_exceeded.
Abrir una cuenta, iniciar sesión desde un celular nuevo, registrar una CLABE o una cuenta para retiros: en una fintech o un marketplace, todos esos momentos esperan un código de seis dígitos. Si el código no llega, el usuario no culpa a tu proveedor de SMS, abandona el registro. En este tutorial vas a enviar código de verificación por WhatsApp y SMS con la API v2 de Zavu, y a cambiar de canal cuando el primero falla.
Lo que vas a tener al final:
- Un código que genera, guarda (hasheado) y valida tu propio backend, con vencimiento y límite de intentos.
- Una plantilla de autenticación de WhatsApp con botón de copiar código, enviada con
POST /v2/messages. - Un respaldo por SMS de una vía o email, movido por el webhook del proyecto.
- El mapa de errores que de verdad aparecen en un flujo de verificación, y qué hacer con cada uno.
¿Primera vez con v2? Lee Zavu 2.0 o ten abierta la guía de la API v2 mientras sigues este post.
Lo que necesitas
- Una cuenta de Zavu y una API key. v2 usa la misma key que v1, como Bearer token. Los SDKs llaman a v1 por ahora, así que aquí llamamos a v2 por HTTP con
fetchycurl. - Al menos dos canales, porque un respaldo necesita a dónde ir. Lo que cuesta cada uno:
| Canal | Qué necesita | Fricción |
|---|---|---|
| Una cuenta de WhatsApp Business conectada con el registro de Meta, desde Canales en el dashboard, y una plantilla de autenticación aprobada | La más alta: registro en Meta y revisión de plantilla | |
| SMS de una vía | POST /v2/channels {"type":"sms_oneway"} | Ninguna para conectar. Sale de números compartidos y no recibe respuestas |
Un dominio de email verificado y luego POST /v2/channels | Registros DNS, una vez | |
| SMS bidireccional | Un número propio (POST /v2/numbers, plan de pago) | Comprar el número |
Para un código, el SMS de una vía suele bastar: nadie necesita contestarle a un OTP. Si algún día tu cuenta tuviera que verificar su empresa para usarlo, la conexión y los envíos responden 403 kyb_required; hoy, por defecto, no se pide.
1. Genera y guarda el código de tu lado
Zavu envía mensajes. No genera ni valida códigos, y no le hace falta: el código es un secreto de tu aplicación, y tenerlo en tu base de datos es lo que te permite reenviarlo por cualquier canal. Lo que sigue es práctica estándar, no una función de Zavu:
- Genéralo con una fuente aleatoria criptográfica, nunca con
Math.random. - Guarda un hash, nunca el código.
- Dale un vencimiento corto (10 minutos es lo habitual) y un máximo de intentos.
- Compara en tiempo constante.
TypeScriptimport { randomInt, createHmac, timingSafeEqual, randomUUID } from "node:crypto"; const PEPPER = process.env.OTP_PEPPER!; // secreto del servidor, fuera de la base de datos 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 con "+", p. ej. "+525512345678" email: user.email ?? null, codeHash: hashCode(id, code), expiresAt: Date.now() + 10 * 60_000, attemptsLeft: 5, step: 0, // en qué canal del plan vamos messageId: null, // el mensaje de Zavu del paso actual 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 es lo que ya uses. La base de datos solo guarda el hash. El mismo código se reutiliza en cada paso del respaldo: si el primer mensaje llega tarde sigue siendo válido, y el usuario nunca ve dos códigos distintos. En el paso 4 verás dónde guardarlo hasta que venza.
2. Crea la plantilla de autenticación de WhatsApp
WhatsApp solo deja que una empresa inicie la conversación con una plantilla, y los códigos van en la categoría AUTHENTICATION. Créala con 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_acceso", "language": "es_MX", "body": "Tu código de verificación es {{1}}.", "whatsappCategory": "AUTHENTICATION", "buttons": [{ "type": "otp", "otpType": "COPY_CODE", "text": "Copiar código" }], "addSecurityRecommendation": true, "codeExpirationMinutes": 10 }'
En una plantilla de autenticación con botón OTP, el texto del mensaje lo escribe WhatsApp; addSecurityRecommendation agrega su línea de "no compartas este código" y codeExpirationMinutes el pie con el vencimiento. Después envíala a la cuenta de WhatsApp Business de tu número, nombrado por su dirección:
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": "+525512345678", "category": "AUTHENTICATION" }'
La respuesta de Meta llega como evento template.status_changed. No la uses antes de que esté aprobada: un envío con una plantilla sin aprobar falla.
3. Enviar código de verificación por SMS y WhatsApp con POST /v2/messages
Cada paso del plan es una llamada. El código va en templateVariables bajo "1"; Zavu lo pone en el texto y en el botón de copiar. El idempotencyKey es por intento, para que un reintento HTTP nunca mande el código dos veces.
TypeScriptconst ZAVU = "https://api.zavu.dev/v2"; type Step = "whatsapp" | "sms_oneway" | "email"; // El orden lo decides tú. 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, // p. ej. "+525512345678" 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}Pago Simple: tu código de verificación es${code}. Vence en 10 minutos., idempotencyKey, }; } return { to: v.email, channel: "email", subject: "Tu código de verificación de Pago Simple", text:Tu código de verificación es${code}. Vence en 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() }; }
Un envío por WhatsApp aceptado responde 202 y te dice cómo se enrutó:
JSON{ "message": { "id": "jd7x2k3m4n5p6q7r8s9t0", "to": "+573001234567", "from": "+525512345678", "channel": "whatsapp", "direction": "outbound", "messageType": "template", "status": "queued", "createdAt": "2026-10-19T15:04:05.000Z" }, "routing": { "channel": "whatsapp", "from": "+525512345678", "reason": "explicit", "estimatedCost": null } }
Guarda message.id en la verificación: así la encuentra el webhook. Empezar una verificación queda en tres líneas:
TypeScriptconst { id, code } = await startVerification(user); await secretStore.set(, code, { ttlSeconds: 600 }); // ver paso 4 await advanceFrom(id, -1); // envía el paso 0otp:${id}
routing.reason es explicit porque el paso nombra su channel. estimatedCost es null en WhatsApp y email, donde el precio no se conoce al enviar; en SMS es lo que el envío descontó de tu saldo prepago.
Nombra el canal en cada paso. Sin channel, v2 elige el primer transporte activo de la dirección (para un teléfono: WhatsApp, luego SMS, luego SMS de una vía). Eso no es un respaldo: decide una vez, antes de que nada se entregue. Zavu también trae un respaldo automático de WhatsApp a SMS, pero solo cubre un número que tenga los dos en la misma dirección, y con channel explícito está apagado salvo que envíes fallbackEnabled: true. Para un flujo entre WhatsApp, SMS de una vía y email, el respaldo es tuyo.
4. Escucha el webhook y pasa al siguiente canal
v2 tiene un solo webhook por proyecto. Suscríbelo a los 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.tuapp.mx/webhooks/zavu", "events": ["message.delivered", "message.failed"] }'
El 201 devuelve webhook.secret una sola vez; guárdalo. Cada entrega viene firmada en X-Zavu-Signature como t=, donde v2 es HMAC_SHA256(secret, "{t}.{body}") sobre el body crudo. Los eventos de mensaje traen data.messageId, data.status, data.errorCode y 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); // responde rápido: sin un 2xx, la entrega se reintenta const v = await db.verifications.findByMessageId(evt.data?.messageId); if (!v || v.status !== "pending") return; // ya verificada, vencida o 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 cubre los fallos claros. No cubre un mensaje que simplemente nunca se entrega: un celular apagado, o un SMS retenido para revisión (queda en queued mientras una persona lo mira). Para eso está el tiempo de espera. Elige uno que encaje con tus usuarios; entre 30 y 45 segundos es un buen punto de partida.
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; } // El código no está en claro en la base de datos. Guárdalo en un almacén de corta vida // (por ejemplo Redis con un TTL igual al vencimiento), o genera uno nuevo aquí y // actualiza codeHash. const code = await secretStore.get(); if (PLAN[next.step] === "email" && !v.email) return advance(next, "sin email"); 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 no puede llevarlo: prueba el siguiente ya } else { await db.verifications.update(v.id, { status: "error", lastReason: result.json.code }); } } // Cuando vence el temporizador: si el paso no cambió y nada se entregó, avanza. 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. Pruébalo, y lo que puede salir mal
Prueba con una key live y tu propio celular. Una key de prueba siempre envía desde el sandbox de WhatsApp: no consulta from, y cualquier channel que no sea WhatsApp responde 400. Para ejercitar el respaldo usa una key live, envía a tu número y haz fallar el primer paso a propósito, por ejemplo con una plantilla todavía pendiente.
Todo error de v2 es plano, {code, message, details}. Estos son los que aparecen en un flujo de verificación:
| Status y código | Cuándo | Qué debe hacer tu código |
|---|---|---|
409 no_channel | La dirección no tiene conexión para el channel que pediste. details.connect dice qué conectar | Saltar al siguiente paso; arreglar la configuración |
403 channel_inactive | La conexión existe pero está apagada | Saltar; activarla |
403 destination_not_verified | Una cuenta que no verificó nada envía SMS solo a los números que verificó | Saltar; verificar identidad o agregar un método de pago |
403 kyb_required | SMS de una vía cerrado a la cuenta hasta verificar la empresa. No aparece mientras el SMS de una vía no pida verificación, que es lo predeterminado | Saltar al siguiente paso |
422 sms_compliance_blocked | La revisión de contenido rechazó el texto del SMS | Corregir el texto; no reintentar igual |
429 rate_limit_exceeded | Un segundo SMS al mismo destinatario dentro del mismo minuto | Pedir que espere, o usar otro canal |
429 daily_limit_exceeded | El tope diario del grupo de canales, details.limit. Se reinicia a las 00:00 UTC | Saltar: los otros grupos tienen su propio tope |
402 insufficient_balance | El SMS se paga del saldo prepago | Saltar; recargar saldo |
409 conflict | El idempotencyKey ya se usó: no se envió de nuevo, el original está en details.message | Tratarlo 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á en la lista por un caso concreto: el SMS a números de Estados Unidos y Canadá (+1) exige una campaña 10DLC aprobada. Si tienes usuarios con número +1, ese paso se salta y el código sale por WhatsApp o email. Más en qué es A2P 10DLC.
Tres puntos merecen más detalle.
El límite de un SMS por minuto es una trampa para el reenvío. Zavu envía como máximo un SMS por destinatario por minuto, en SMS bidireccional y de una vía. Un botón de "reenviar código" que dispara un segundo SMS antes de 60 segundos recibe 429 rate_limit_exceeded. Desactiva el botón un minuto, o haz que el reenvío salga por otro canal.
Escribe el SMS para que se lea como código. Cada SMS se revisa antes de enviarse. Un texto con una palabra como "código" o "verificación" y un número de 4 a 8 dígitos se reconoce como código de un solo uso y pasa de inmediato. Mantén el texto simple: nombre de la app, código y vencimiento. No pidas que compartan, reenvíen o respondan con el código: es el lenguaje del phishing.
No pongas links. Los links en SMS tienen que estar verificados antes de poder enviarse, y los acortadores de URL siempre se bloquean. Un código de verificación no necesita link.
6. A producción
Topes diarios. Los canales comparten presupuestos y cada uno tiene su tope. Una cuenta que no verificó nada envía 25 SMS y 100 mensajes conversacionales (WhatsApp, Telegram, Instagram, Messenger) al día; el SMS de una vía cuenta dentro de los 25. Verificarse (identidad, un método de pago guardado, un depósito o un plan de pago) sube el SMS a 200 al día, y la verificación de identidad o de empresa lo sube a 10,000. Lo conversacional no tiene tope diario en planes de pago. El email sigue la cuota del plan: 100 al día y 3,000 al mes en Free. Como los presupuestos son distintos, un respaldo que pasa de WhatsApp a SMS también cambia de tope. Detalle en límites de envío.
Costos. Meta cobra los mensajes de WhatsApp a tu cuenta de WhatsApp Business, y las plantillas de autenticación se cobran por mensaje según el país del destinatario. En Free, los mensajes de WhatsApp también cuentan dentro de los 2,000 al mes que comparten los canales de apps, en ambas direcciones. El SMS se descuenta por mensaje de tu saldo en Zavu, y routing.estimatedCost muestra cuánto tomó cada envío.
Protege el endpoint que envía códigos. Todo endpoint que manda SMS atrae SMS pumping: bots que piden códigos hacia números de tarificación especial para que alguien más cobre la terminación. Zavu revisa los SMS salientes, y aun así conviene cuidar tu lado:
- Limita las solicitudes de código por usuario, por número y por IP.
- Permite solo los países donde operas: si tu fintech atiende México, Colombia y Chile, rechaza el resto.
- Pon un CAPTCHA u otro desafío antes del formulario.
- Limita cuántos códigos se pueden pedir por verificación y por día.
- Alerta ante un salto repentino de solicitudes hacia un país nuevo.
Vence y limpia. Marca las verificaciones como vencidas al cerrar la ventana, borra el código del almacén temporal y nunca lo escribas en logs.
El flujo completo, en una línea: hashea el código de tu lado, envíalo con POST /v2/messages por el canal que elijas, y deja que message.failed y un temporizador lo pasen al siguiente. Para mejorar la tasa de entrega, lee optimización de entrega de mensajes; como referencia, errores de la API v2 y el webhook de v2.