TL;DR
- Your backend owns the code. Zavu sends it; you generate it, store a hash, and set an expiry and an attempt limit.
- On WhatsApp the code goes in an authentication template with a copy-code button, sent only once Meta approves it.
- Name the channel on every step. Without
channel, v2 picks a transport once, which is not a fallback. message.failedand a timer move the code to the next channel, through the project webhook.- Zavu sends at most one SMS per recipient per minute, so a resend inside 60 seconds gets
429 rate_limit_exceeded.
Sign-up, login, a new device, a payout to a new bank account: every one of those moments waits on a six-digit code. When the code does not arrive, the user does not blame your messaging provider. They abandon the sign-up. This tutorial builds an OTP API flow on Zavu API v2 that sends verification codes on one channel and moves to the next when the first one fails.
What you will have at the end:
- A code generated, hashed and checked by your own backend, with an expiry and an attempt limit.
- A WhatsApp authentication template with a copy-code button, sent through
POST /v2/messages. - A fallback to email (or SMS, once you have 10DLC) driven by the project webhook.
- A map of the errors a verification flow actually meets, and what to do with each one.
New to v2? Read Zavu 2.0 first, or keep the API v2 overview open beside this post.
What you need
- A Zavu account and an API key. v2 uses the same key as v1, as a Bearer token. The SDKs call v1 today, so this tutorial calls v2 over HTTP with
fetchandcurl. - At least two channels, because a fallback needs somewhere to fall back to. What each one costs you to set up:
| Channel | What it needs | Friction |
|---|---|---|
| A WhatsApp Business Account connected through Meta's signup, from Channels in the dashboard, plus an approved authentication template | The most setup: Meta signup and template review | |
A verified email domain, then POST /v2/channels | DNS records, once | |
| One-way SMS | POST /v2/channels {"type":"sms_oneway"} | None to connect. To +1 numbers it needs an approved 10DLC campaign |
| Two-way SMS | A number of your own (POST /v2/numbers, paid plan) | To +1 numbers it needs an approved 10DLC campaign |
The US-specific part. Every sms and sms_oneway message to a +1 number is refused with 403 ten_dlc_required until your project has an approved 10DLC campaign, and carrier review takes days. If you have not registered yet, ship with WhatsApp and email, and add SMS when the campaign is approved. More in What is A2P 10DLC.
1. Generate and store the code on your side
Zavu sends messages. It does not generate or check codes, and it does not need to: the code is a secret of your application, and keeping it in your database is what lets you resend it on any channel. What follows is standard practice, not a Zavu feature:
- Generate it with a cryptographic random source, never
Math.random. - Store a hash of it, never the code itself.
- Give it a short expiry (10 minutes is common) and a maximum number of attempts.
- Compare in constant time.
TypeScriptimport { randomInt, createHmac, timingSafeEqual, randomUUID } from "node:crypto"; const PEPPER = process.env.OTP_PEPPER!; // a server-side secret, not in the database 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, with the "+" email: user.email ?? null, codeHash: hashCode(id, code), expiresAt: Date.now() + 10 * 60_000, attemptsLeft: 5, step: 0, // which channel of the plan we are on messageId: null, // the Zavu message of the current step 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 is whatever you already use. The database holds only the hash. The same code is reused on every fallback step, so a first message that arrives late is still valid and the user never sees two codes that disagree; step 4 shows where to keep it until it expires.
2. Create the WhatsApp authentication template
WhatsApp only lets a business start a conversation with a template, and verification codes go in the AUTHENTICATION category. Create it with 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": "login_code", "language": "en_US", "body": "{{1}} is your verification code.", "whatsappCategory": "AUTHENTICATION", "buttons": [{ "type": "otp", "otpType": "COPY_CODE", "text": "Copy code" }], "addSecurityRecommendation": true, "codeExpirationMinutes": 10 }'
For an authentication template with an OTP button, WhatsApp writes the message text itself; addSecurityRecommendation adds its "do not share this code" line and codeExpirationMinutes its expiry footer. Then submit it to the WhatsApp Business Account of your number, named by its address:
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": "+13125551212", "category": "AUTHENTICATION" }'
Meta's answer arrives as a template.status_changed event. Do not send it before it is approved: a send with an unapproved template fails.
3. Send verification codes with POST /v2/messages
Each step of your plan is one call. The code goes in templateVariables under "1"; Zavu puts that value in the body and in the copy-code button. idempotencyKey is per attempt, so a retried HTTP request never sends the code twice.
TypeScriptconst ZAVU = "https://api.zavu.dev/v2"; type Step = "whatsapp" | "email" | "sms"; // The order is yours. Without 10DLC, keep "sms" out of the plan for +1 numbers. const PLAN: Step[] = ["whatsapp", "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, // e.g. "+13125551212" channel: "whatsapp", messageType: "template", content: { templateId: process.env.OTP_TEMPLATE_ID, templateVariables: { "1": code } }, idempotencyKey, }; } if (step === "email") { return { to: v.email, channel: "email", subject: "Your Acme verification code", text:otp_${v.id}_${n}Your Acme verification code is${code}. It expires in 10 minutes., idempotencyKey, }; } return { to: v.phone, channel: "sms", text:Acme: your verification code is${code}. It expires in 10 minutes., idempotencyKey, }; } export async function sendStep(v: any, code: string) { const step = PLAN[v.step]; const res = await fetch(${ZAVU}/messages, { method: "POST", headers: { Authorization:Bearer${process.env.ZAVUDEV_API_KEY}, "Content-Type": "application/json" }, body: JSON.stringify(payload(step, v, code, v.step)), }); const json = await res.json(); return { status: res.status, json }; }
A WhatsApp send that Zavu accepts answers 202, and tells you how it was routed:
JSON{ "message": { "id": "jd7x2k3m4n5p6q7r8s9t0", "to": "+14155550123", "from": "+13125551212", "channel": "whatsapp", "direction": "outbound", "messageType": "template", "status": "queued", "createdAt": "2026-10-19T15:04:05.000Z" }, "routing": { "channel": "whatsapp", "from": "+13125551212", "reason": "explicit", "estimatedCost": null } }
Save message.id on the verification row: it is how the webhook finds it. Starting a verification is then three lines:
TypeScriptconst { id, code } = await startVerification(user); await secretStore.set(, code, { ttlSeconds: 600 }); // see step 4 await advanceFrom(id, -1); // sends step 0otp:${id}
routing.reason is explicit because the step names its channel. estimatedCost is null on WhatsApp and email, where the price is not known at send time; on SMS it is what the send took from your prepaid balance.
Name the channel on every step. Without channel, v2 picks the first active transport of the address (for a phone number: WhatsApp, then SMS, then one-way SMS), which is not a fallback: it decides once, before anything is delivered. Zavu also has a built-in WhatsApp-to-SMS fallback, but it only covers a number that carries both on the same address, and it is off when channel is explicit unless you set fallbackEnabled: true. For a verification flow across WhatsApp, email and SMS, the fallback below is yours.
4. Listen to the webhook and move to the next channel
v2 has one webhook for the whole project. Subscribe it to the delivery events:
terminalcurl -X PUT https://api.zavu.dev/v2/webhook \ -H "Authorization: Bearer $ZAVUDEV_API_KEY" \ -H "Content-Type: application/json" \ -d '{ "url": "https://api.example.com/webhooks/zavu", "events": ["message.delivered", "message.failed"] }'
The 201 returns webhook.secret once; store it. Every delivery is signed in X-Zavu-Signature as t=, where v2 is HMAC_SHA256(secret, "{t}.{body}") over the raw body. Message events carry data.messageId, data.status, data.errorCode and 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); // answer fast; deliveries without a 2xx are retried const v = await db.verifications.findByMessageId(evt.data?.messageId); if (!v || v.status !== "pending") return; // already verified, expired, or a retried delivery 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 covers the clear failures. It does not cover a message that is simply never delivered: a phone that is off, or an SMS held for review (it stays queued while a person looks at it). That is what a timeout is for. Pick one that fits your users; 30 to 45 seconds is a reasonable start.
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; } // The code is not stored in clear. Keep it in a short-lived secret store keyed by // verification id (for example Redis with a TTL equal to the expiry), or issue a new // code here and update codeHash. const code = await secretStore.get(); if (PLAN[next.step] === "email" && !v.email) return advance(next, "no 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); // this channel cannot carry it: try the next one now } else { await db.verifications.update(v.id, { status: "error", lastReason: result.json.code }); } } // When the timer fires: if the step has not moved and nothing was delivered, advance. 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}
If you would rather not keep the code in a cache, issue a fresh code on each step and replace codeHash. Both are sound; reusing one code is what keeps a late message valid.
5. Test it, and what can go wrong
Test with a live key, on your own phone. A test-mode key always sends from the WhatsApp sandbox: from is not consulted, and a channel other than WhatsApp is refused with 400. To exercise the fallback, use a live key, send to your own number, and make the first step fail on purpose, for example with a template that is still pending.
Every v2 error is flat, {code, message, details}. These are the ones a verification flow meets:
| Status and code | When | What your code should do |
|---|---|---|
409 no_channel | The address has no connection for the channel you named. details.connect says what to connect | Skip to the next step; fix the setup |
403 ten_dlc_required | sms or sms_oneway to a +1 number, no approved 10DLC campaign. details.alternatives lists WhatsApp, Telegram and email | Skip to the next step |
403 channel_inactive | The named connection exists but is switched off | Skip; activate it |
403 destination_not_verified | An account that has verified nothing sends SMS only to the numbers it verified | Skip; verify your identity or add a payment method |
403 kyb_required | One-way SMS closed to the account until business verification. Not returned while one-way SMS needs no verification, which is the default | Skip to the next step |
422 sms_compliance_blocked | The SMS content check refused the text | Fix the template text; do not retry as is |
429 rate_limit_exceeded | A second SMS to the same recipient within a minute | Ask the user to wait, or use another channel |
429 daily_limit_exceeded | The channel group's daily ceiling, details.limit. Resets at 00:00 UTC | Skip: other channel groups have their own ceiling |
402 insufficient_balance | SMS is prepaid from your balance | Skip; top up |
409 conflict | The idempotencyKey was already used: nothing was sent again, the original is in details.message | Treat as sent |
TypeScriptconst SKIP = new Set([ "no_channel", "ten_dlc_required", "channel_inactive", "destination_not_verified", "kyb_required", "daily_limit_exceeded", "insufficient_balance", ]); const isSkippable = (code: string) => SKIP.has(code);
Three of these deserve a closer look.
The one-minute SMS limit is a resend trap. Zavu sends at most one SMS per recipient per minute, on two-way and one-way SMS alike. A "resend code" button that fires a second SMS within 60 seconds gets 429 rate_limit_exceeded. Disable the button for a minute, or make the resend go out on another channel.
Write the SMS so it reads as a code. Every SMS is read before it is sent. A text with a code word ("code", "verification") and a 4 to 8 digit number is recognized as a one-time code and allowed straight away. Avoid "share", "forward" or "reply with your code": that is the language of phishing, and it sends the message through the full review.
Leave links out. SMS links must be verified before they can be sent, and URL shorteners are always blocked. A verification code does not need a link.
6. Going to production
Daily ceilings. Channels share budgets, and each budget has its own ceiling. An account that has verified nothing sends 25 SMS and 100 conversational messages (WhatsApp, Telegram, Instagram, Messenger) a day. Verifying (identity, a saved payment method, a deposit or a paid plan) raises SMS to 200 a day, and identity or business verification raises it to 10,000. Conversational has no daily ceiling on paid plans. Email follows the plan quota: 100 a day and 3,000 a month on Free. Because the budgets are separate, a fallback that crosses from WhatsApp to email also crosses ceilings. Details in Sending limits.
Costs. WhatsApp message fees are billed by Meta to your WhatsApp Business Account, and authentication templates are billed by Meta per message. On Free, WhatsApp messages also count toward the 2,000 a month the app channels share, in both directions. SMS is prepaid from your Zavu balance per message, and routing.estimatedCost shows what each send took.
Protect the endpoint that sends codes. Every SMS endpoint on the internet attracts SMS pumping: bots that request codes to premium-rate numbers so someone else collects the termination fee. Zavu reviews outbound SMS for this, and you should still guard your side:
- Rate limit code requests per user, per phone number and per IP.
- Allow only the countries you actually serve.
- Put a CAPTCHA or another challenge in front of the request form.
- Cap the total number of codes per verification and per day.
- Alert on a sudden jump in requests to a new country.
Expire and clean up. Mark verifications expired after the window, delete the cached code, and never log the code.
The full flow, in one line: hash the code on your side, send it with POST /v2/messages on the channel you choose, and let message.failed and a timer move it to the next one. For more on delivery rates, read message delivery optimization; for the reference, API v2 errors and the v2 webhook.