TutorialAPI v2OTP2FAWhatsAppEmailSMS

OTP API: Send Verification Codes with a Fallback Channel (Zavu API v2)

Build an OTP API flow with Zavu API v2: generate and hash the code yourself, send verification codes on WhatsApp, fall back to email, handle the real errors.

Written by: Zavu TeamReviewed by: Jennifer VillalobosOctober 19, 202611 min read
View as Markdown

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.failed and 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 fetch and curl.
  • At least two channels, because a fallback needs somewhere to fall back to. What each one costs you to set up:
ChannelWhat it needsFriction
WhatsAppA WhatsApp Business Account connected through Meta's signup, from Channels in the dashboard, plus an approved authentication templateThe most setup: Meta signup and template review
EmailA verified email domain, then POST /v2/channelsDNS records, once
One-way SMSPOST /v2/channels {"type":"sms_oneway"}None to connect. To +1 numbers it needs an approved 10DLC campaign
Two-way SMSA 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.
TypeScript
import { 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(${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, 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; }

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:

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

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

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

TypeScript
const { id, code } = await startVerification(user); await secretStore.set(otp:${id}, code, { ttlSeconds: 600 }); // see step 4 await advanceFrom(id, -1); // sends step 0

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:

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.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=,v2=, where v2 is HMAC_SHA256(secret, "{t}.{body}") over the raw body. Message events carry data.messageId, data.status, data.errorCode and 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); // 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, 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.

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

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 codeWhenWhat your code should do
409 no_channelThe address has no connection for the channel you named. details.connect says what to connectSkip to the next step; fix the setup
403 ten_dlc_requiredsms or sms_oneway to a +1 number, no approved 10DLC campaign. details.alternatives lists WhatsApp, Telegram and emailSkip to the next step
403 channel_inactiveThe named connection exists but is switched offSkip; activate it
403 destination_not_verifiedAn account that has verified nothing sends SMS only to the numbers it verifiedSkip; verify your identity or add a payment method
403 kyb_requiredOne-way SMS closed to the account until business verification. Not returned while one-way SMS needs no verification, which is the defaultSkip to the next step
422 sms_compliance_blockedThe SMS content check refused the textFix the template text; do not retry as is
429 rate_limit_exceededA second SMS to the same recipient within a minuteAsk the user to wait, or use another channel
429 daily_limit_exceededThe channel group's daily ceiling, details.limit. Resets at 00:00 UTCSkip: other channel groups have their own ceiling
402 insufficient_balanceSMS is prepaid from your balanceSkip; top up
409 conflictThe idempotencyKey was already used: nothing was sent again, the original is in details.messageTreat as sent
TypeScript
const 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.

Frequently asked questions

Does Zavu generate or verify the OTP code for me?
No. Zavu delivers the message; your backend generates the code, stores a hash of it with an expiry and an attempt counter, and checks what the user types. Keeping that logic on your side means the code never depends on a message provider, and you can resend the same code on another channel without a second source of truth.
Can I send OTP codes by SMS to US numbers without 10DLC?
No. SMS and one-way SMS to any +1 number are refused with 403 ten_dlc_required until the project has an approved 10DLC campaign. The error's details.alternatives lists channels that need no registration: WhatsApp reaches the same number, and email or Telegram reach the same person by another identifier.
How do I send a WhatsApp verification code with a copy button?
Create a WhatsApp template in the AUTHENTICATION category with an otp button of type COPY_CODE, submit it to Meta, and wait for approval. Then send it with messageType template and the code in templateVariables under the key 1. Zavu places that value in both the message body and the copy button.
Why does my second SMS code fail with 429 rate_limit_exceeded?
Zavu allows one SMS per recipient per minute, on both two-way and one-way SMS. A user who taps resend within 60 seconds of the last SMS hits that limit. Disable the resend button for a minute, or move the resend to another channel, which this limit does not cover.
How should an OTP flow fall back to another channel?
Send on the first channel, then move to the next one when the webhook reports message.failed or when no message.delivered arrives within a timeout you choose. Send the same code each time, with a new idempotencyKey per attempt, so a late first message is still valid and a retried request never sends twice.
Does Zavu's SMS content check block verification codes?
A message that contains a code word such as code or verification and a 4 to 8 digit number is recognized as a one-time code and allowed without further review. Words that ask the user to share, forward or reply with the code look like phishing and send the message through the full content check instead.

Need help? Contact us or join our Discord community for support.

Get started

Ready to get started?

Start building for free, or schedule a call to discuss your specific use case.

OTP API: Send Verification Codes with Fallback | Zavu Blog