Outside the US, a verification text is often the most expensive way to send a code, and in many countries WhatsApp reaches the same people for less. So it's worth trying WhatsApp first.
This post builds that in about 40 lines of Node: send the code on WhatsApp, watch for a delivery failure for a few seconds, and fall back to SMS only when WhatsApp can't reach the number. Checking the code works the same either way.
I work on MyOTP.App, so the example uses our API. It's on RapidAPI with a free plan, 100 credits a month and no card, which is enough to follow along.
Get a key
Subscribe to the Basic plan on the MyOTP.App listing. RapidAPI gives you a key for the x-rapidapi-key header.
Keep it on your server. A key in browser JavaScript is a key anyone can copy, and your credits go with it.
Send a code
curl -X POST https://myotp-app-2fa-sms.p.rapidapi.com/generate_otp \
-H "x-rapidapi-key: YOUR_KEY" -H "x-rapidapi-host: myotp-app-2fa-sms.p.rapidapi.com" \
-H "Content-Type: application/json" \
-d '{"phone_number": "14155550123", "channel": "sms"}'
Use your own number. The phone number is digits only, country code first, no plus sign. The answer:
{
"message_id": "429a1de7-a3a5-4148-ae14-56cbcdbc2772",
"status": "accepted",
"message": "OTP sent",
"date_sent": "2026-09-23T01:33:12.402113",
"expires_at": "2026-09-23T01:38:12.402113",
"cost": 1.0
}
Times are UTC with no offset, so add a Z before you parse them in JavaScript. cost is the credits the send used, counted when the send is accepted. The code is 6 digits and lives 5 minutes unless you pass otp_length (3 to 8) or otp_validity (30 to 14400 seconds).
Check it
curl -X POST https://myotp-app-2fa-sms.p.rapidapi.com/verify_otp \
-H "x-rapidapi-key: YOUR_KEY" -H "x-rapidapi-host: myotp-app-2fa-sms.p.rapidapi.com" \
-H "Content-Type: application/json" \
-d '{"message_id": "429a1de7-a3a5-4148-ae14-56cbcdbc2772", "otp": "123456"}'
{"status": "success", "message": "OTP verified"}
{"status": "failed", "reason": "invalid", "message": "OTP does not match"}
A failed check says why in reason: invalid, expired or not found. Checks don't use credits. A code that passed is deleted, so checking it a second time answers not found.
WhatsApp first, SMS if it fails
When a number can't receive WhatsApp, we hear about it fast. In our logs that failure usually comes back within 2 to 3 seconds. So the plan is: send on WhatsApp, read the status for about 10 seconds, and switch to SMS only on a failure.
Node 18 or later, no dependencies:
const HOST = "myotp-app-2fa-sms.p.rapidapi.com"
async function call(path, body) {
const res = await fetch(`https://${HOST}${path}`, {
method: "POST",
headers: {
"x-rapidapi-key": process.env.RAPIDAPI_KEY,
"x-rapidapi-host": HOST,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
})
const data = await res.json().catch(() => ({}))
if (!res.ok) throw new Error(`${res.status}: ${data.error?.message ?? data.message}`)
return data
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
// Returns the message_id to verify against.
export async function sendCode(phone) {
const wa = await call("/generate_otp", { phone_number: phone, channel: "whatsapp" })
for (let i = 0; i < 5; i++) {
await sleep(2000)
const { DLR } = await call("/check_otp_status", { message_id: wa.message_id })
if (String(DLR).startsWith("failed")) {
const sms = await call("/generate_otp", {
phone_number: phone,
channel: "sms",
force_send: "true",
})
return sms.message_id
}
if (DLR === "delivered" || DLR === "read") break
}
return wa.message_id
}
export async function checkCode(messageId, code) {
const r = await call("/verify_otp", { message_id: messageId, otp: code })
return r.status === "success"
}
Two details in there matter.
force_send: "true" on the SMS. The WhatsApp code is still live, and a second send to the same number without it answers 409.
The SMS is a new code with a new message_id. sendCode returns whichever one went out last, so that's the one you verify. Each attempt is charged when it's accepted, so a fallback costs the WhatsApp credit plus the SMS.
If WhatsApp only ever says sent, the loop gives up and keeps the WhatsApp code. That's on purpose. The code may still arrive, and sending a second one by SMS leaves your user holding two codes and guessing which. Give them a "Text me instead" button that makes the same force_send call.
On WhatsApp the code lives 5 or 10 minutes, set by the template. otp_validity doesn't change that, though it still has to be in range. template_order 12 is English with 5 minutes, 13 is English with 10. 14 and 15 are the same in Spanish.
Telegram is 1 credit everywhere and reaches numbers that have a Telegram account. Codes there are 4 to 8 digits and live up to an hour.
Before you ship
- Cap wrong guesses in your app. Five tries, then ask the user to request a new code.
- Put a cooldown on your resend button. Every send spends credits.
- A resend while a code is still live needs
force_send: "true", or it gets a 409. That includes the next send after a WhatsApp fallback. -
/check_otp_statusgives you delivery per message. For SMS that's the carrier's own word, such asDELIVRDorUNDELIV, andATESwhile it's still on its way. For WhatsApp it moves through sent, delivered and read, or ends in a value starting withfailed. Telegram can also reportexpiredorrevoked. Read it before you verify, since a verified code is gone.
What it costs
A code costs a different number of credits depending on where it goes and how it gets there:
| Country | SMS | Telegram | |
|---|---|---|---|
| USA | 1 | 1 | 1 |
| Brazil | 3 | 1 | 1 |
| Mexico | 8 | 1 | 1 |
| India | 9 | 3 | 1 |
| Philippines | 23 | 1 | 1 |
| Nigeria | 24 | 7 | 1 |
On the free plan that's 100 codes to the Philippines over WhatsApp, or 4 by SMS.
| Plan | Price | Credits a month |
|---|---|---|
| Basic | Free | 100 |
| Pro | $25 | 1,000 |
| Ultra | $100 | 5,000 |
| Mega | $180 | 10,000 |
Every country and channel is on myotp.app/pricing. For more volume, write to sales@myotp.app.
If something in here doesn't match what you see, tell me in the comments. I read them.
Top comments (0)