DEV Community

Raya
Raya

Posted on

Phone verification on a free RapidAPI key: WhatsApp first, SMS when it fails

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"}'
Enter fullscreen mode Exit fullscreen mode

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
}
Enter fullscreen mode Exit fullscreen mode

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"}'
Enter fullscreen mode Exit fullscreen mode
{"status": "success", "message": "OTP verified"}
{"status": "failed", "reason": "invalid", "message": "OTP does not match"}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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_status gives you delivery per message. For SMS that's the carrier's own word, such as DELIVRD or UNDELIV, and ATES while it's still on its way. For WhatsApp it moves through sent, delivered and read, or ends in a value starting with failed. Telegram can also report expired or revoked. 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 WhatsApp 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)