--- title: Cómo enviar un código de verificación por WhatsApp y SMS con API, con canal de respaldo description: 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. date: 2026-10-19 author: Zavu Team locale: es source: https://www.zavu.dev/es/blog/otp-verification-codes-api tags: Tutorial, API v2, OTP, 2FA, WhatsApp, SMS, Fintech --- # Cómo enviar un código de verificación por WhatsApp y SMS con API, con canal de respaldo ## 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](/es/blog/zavu-2-0) o ten abierta la [guía de la API v2](https://docs.zavu.dev/api-v2/overview) 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: | Canal | Qué necesita | Fricción | |-------|--------------|----------| | WhatsApp | 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 | | Email | 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. ```ts 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 { 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`: ```bash 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: ```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": "+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. ```ts 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: ```ts 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: ```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.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`. ```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); // 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. ```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; } // 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ó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 | ```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á 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](/es/blog/what-is-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](https://docs.zavu.dev/concepts/sending-limits). **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](/es/blog/message-delivery-optimization); como referencia, [errores de la API v2](https://docs.zavu.dev/api-v2/errors) y [el webhook de v2](https://docs.zavu.dev/api-v2/webhook).