TutorialAPI v2OTP2FAWhatsAppSMSFintech

Cómo enviar un código de verificación por WhatsApp y SMS con API, con canal de respaldo

Tutorial para enviar código de verificación por SMS y WhatsApp con la API v2 de Zavu: genera y guarda el código tú, envíalo y cambia de canal si no llega.

Redactado por: Zavu TeamRevisado por: Jennifer Villalobos19 de octubre de 202611 min de lectura
Ver como Markdown

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.failed y 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 fetch y curl.
  • Al menos dos canales, porque un respaldo necesita a dónde ir. Lo que cuesta cada uno:
CanalQué necesitaFricción
WhatsAppUna cuenta de WhatsApp Business conectada con el registro de Meta, desde Canales en el dashboard, y una plantilla de autenticación aprobadaLa más alta: registro en Meta y revisión de plantilla
SMS de una víaPOST /v2/channels {"type":"sms_oneway"}Ninguna para conectar. Sale de números compartidos y no recibe respuestas
EmailUn dominio de email verificado y luego POST /v2/channelsRegistros DNS, una vez
SMS bidireccionalUn 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.
TypeScript
import { 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(${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 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; }

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:

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

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

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

TypeScript
const { id, code } = await startVerification(user); await secretStore.set(otp:${id}, code, { ttlSeconds: 600 }); // ver paso 4 await advanceFrom(id, -1); // envía el paso 0

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:

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

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); // 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, 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.

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

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ódigoCuándoQué debe hacer tu código
409 no_channelLa dirección no tiene conexión para el channel que pediste. details.connect dice qué conectarSaltar al siguiente paso; arreglar la configuración
403 channel_inactiveLa conexión existe pero está apagadaSaltar; activarla
403 destination_not_verifiedUna 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_requiredSMS 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 predeterminadoSaltar al siguiente paso
422 sms_compliance_blockedLa revisión de contenido rechazó el texto del SMSCorregir el texto; no reintentar igual
429 rate_limit_exceededUn segundo SMS al mismo destinatario dentro del mismo minutoPedir que espere, o usar otro canal
429 daily_limit_exceededEl tope diario del grupo de canales, details.limit. Se reinicia a las 00:00 UTCSaltar: los otros grupos tienen su propio tope
402 insufficient_balanceEl SMS se paga del saldo prepagoSaltar; recargar saldo
409 conflictEl idempotencyKey ya se usó: no se envió de nuevo, el original está en details.messageTratarlo 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á 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.

Preguntas frecuentes

¿Zavu genera o valida el código de verificación?
No. Zavu entrega el mensaje; tu backend genera el código, guarda un hash con su vencimiento y un límite de intentos, y compara lo que escribe el usuario. Así el código no depende de ningún proveedor de mensajería y puedes reenviar el mismo código por otro canal sin tener dos fuentes de verdad.
¿Cómo envío un código de verificación por WhatsApp con botón de copiar?
Crea una plantilla de WhatsApp de categoría AUTHENTICATION con un botón otp de tipo COPY_CODE, envíala a revisión de Meta y espera la aprobación. Después mándala con messageType template y el código en templateVariables bajo la clave 1. Zavu pone ese valor en el texto y en el botón de copiar.
¿Necesito número propio para enviar códigos por SMS en México, Colombia o Chile?
No necesariamente. El SMS de una vía (sms_oneway) se conecta con una sola llamada, sin comprar número, y sale desde números compartidos; el usuario no puede responder, lo que para un código no importa. El SMS bidireccional sí necesita un número propio, que se compra con un plan de pago.
¿Por qué mi segundo SMS falla con 429 rate_limit_exceeded?
Zavu permite un SMS por destinatario por minuto, tanto en SMS bidireccional como de una vía. Si el usuario toca reenviar antes de 60 segundos desde el último SMS, choca con ese límite. Desactiva el botón durante un minuto o haz que el reenvío salga por otro canal, al que este límite no aplica.
¿Cómo debe cambiar de canal un flujo de OTP?
Envía por el primer canal y pasa al siguiente cuando el webhook reporte message.failed o cuando no llegue message.delivered dentro del tiempo que tú definas. Manda el mismo código en cada intento, con un idempotencyKey nuevo por intento, para que un mensaje atrasado siga siendo válido y un reintento HTTP no envíe dos veces.
¿La revisión de contenido de SMS de Zavu bloquea los códigos?
Un mensaje que tiene 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 se permite sin más revisión. Mantén el texto simple: el nombre de tu app, el código y el vencimiento, sin pedir que lo compartan ni lo reenvíen.

Necesitas ayuda? Contáctanos o únete a nuestra comunidad en Discord para soporte.

Get started

Listo para empezar?

Comienza a construir gratis, o agenda una llamada para discutir tu caso de uso específico.

Código de verificación por SMS y WhatsApp | Zavu Blog