DEV Community

mT41Gzp73rc6
mT41Gzp73rc6

Posted on

Picking an SMS OTP API When Your 2FA Flow Needs Resend, Cancel and Per-Country Throttles

TL;DR

Send 2FA login codes over SMS, and pick an API that treats resend and cancel as real operations instead of leaving you to fake them with a second send. Email belongs in this design as a fallback, not as the primary channel — scheduled email generally lacks a cancel call, and inbox delivery time isn't yours to control. Then budget a day for the throttle layer, because no vendor ships one shaped like your abuse profile.

I've shipped this flow four times. The send was never the hard part.

The invariants that survive contact with an abusive signup form

Your OTP endpoint is a public, unauthenticated, paid write endpoint. That one sentence decides most of the design.

Last spring I under-priced exactly that. We had a phone field on a self-serve signup page, a 30-second cooldown enforced only in the browser, and no country allowlist, because the product was US and EU only and I assumed the traffic would be too. Over a single weekend a scripted client walked that form against a range of premium-rate prefixes in a country we've never sold into, hitting the resend path roughly 9,000 times. The monthly estimate I'd handed finance was about $300. That weekend alone came to $1,940, and I found out from a billing alert rather than from my own dashboards, which tells you how much attention I'd paid to the write path. Nobody's account was compromised — the abuse rode through our form to collect revenue on the terminating leg. My fix was boring: server-side cooldowns keyed on user and IP, a country allowlist, a hard daily ceiling per account, and a spend circuit breaker that pages me. All of it lives in my code, not the vendor's.

So the invariants now go on paper before I compare anyone. One live code per user and purpose at a time. A resend that either re-delivers the same code or explicitly replaces it — pick one, then write it down, because your support team will be asked. A cancel that kills the pending code the second the user edits their number or walks away from the login screen. Five verification attempts, five minutes of TTL, both enforced server-side. And every send attributable to a throttle bucket: user, IP, device, country prefix.

Cancel is the invariant people skip. It's also the one that shows up in tickets, because a user who mistypes a digit of their number and corrects it now has two codes racing each other, and the one that arrives is the one that no longer verifies.

The last piece is knowing whether the message actually left the network. Some providers push delivery webhooks; others expose a pull-only event list, which means a small polling loop keyed on the message id and a UI that honestly says "resend available in 30s" instead of pretending it knows. Either is workable. Pretending you have delivery confirmation when you don't is what generates the angry tickets.

Which API should I pick for SMS OTP with resend and cancel support in a US/EU app?

Four options are worth real evaluation, and they differ less on features than on how much of the abuse surface they own for you.

Option How you integrate Resend / cancel Delivery state Where it fits
Twilio Verify SDK or REST, per-service config Built in, with fraud controls and geo permissions Webhooks plus status lookup You want the abuse controls bought, not built
Vonage Verify REST, workflow-based Built in; cancel ends the workflow Webhooks SMS-then-voice fallback matters to you
Plivo Verify REST, session-based Built in Webhooks Small surface, fewer knobs to get wrong
Infrai SMS One REST API described by discovery OTP, verify, resend and cancel routes Pull-only event list Your backend already sits behind one key
Raw SMS API (Twilio Programmable SMS, Amazon SNS) You store and compare codes yourself You build both Varies You need custom code storage or hashing

Twilio Verify is the most mature of these, and its geo permissions plus fraud controls are the reason I'd still hand it to a team that has never run this endpoint in anger: the per-country blast radius becomes a config screen instead of a migration. The cost of that maturity is that you adopt their service model, their template rules and their pricing shape. Vonage and Plivo cover the same lifecycle with smaller surfaces; Vonage's workflow model is the one I'd pick if a voice fallback is genuinely on the roadmap rather than aspirational.

Infrai is what I reach for when the login service already sits next to storage, cron and logging behind one key and one REST API: the SMS namespace covers issuing an OTP, verifying it, resending it and cancelling a pending send, and the capabilities are self-describing, so wiring the flow is reading one endpoint's schema and runnable example rather than installing an SDK and learning its object model. The catch is that comm events there are pull-only, so a delivery dashboard means polling on your side, and there's no voice or WhatsApp leg to fall back to when termination in a given market is weak. I'm not sure the pull model would bother me at 50 messages a minute; at 5,000 I'd want to measure it first.

Rolling your own on a raw SMS API is the right call in exactly one case: you have a compliance reason to control how codes are generated, hashed and stored. Otherwise you are rebuilding rate limits, TTLs and attempt counters that someone else already tested.

None of these will decide which countries you sell to. That list is yours, and it's the single highest-leverage line of code in the whole flow.

Wiring the send, resend and verify path in one service

Three calls carry the whole login experience. Here's the shape I use, with the two things I refuse to ship without — an idempotency key on every write so a retried request never mints a second code, and backoff that honours Retry-After instead of hammering a limiter.

import os
import uuid
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry

# Both come from the environment: the API origin from your vendor's docs, the key from your secret store.
BASE = os.environ["INFRAI_API_BASE"]

client = requests.Session()
client.headers.update({
    "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}",
    "Content-Type": "application/json",
})
client.mount("https://", HTTPAdapter(max_retries=Retry(
    total=4, backoff_factor=1.5, status_forcelist=[429],
    allowed_methods=["POST"], respect_retry_after_header=True)))


def checked(resp):
    if resp.status_code >= 400:
        raise RuntimeError(f"{resp.url} -> {resp.status_code}: {resp.text[:200]}")
    return resp.json()


def start_login(phone, login_id):
    # Key it on the login attempt: a retried POST resolves to the same code, not a second one.
    return checked(client.post(
        f"{BASE}/v1/sms/otp",
        json={"phone": phone},
        headers={"Idempotency-Key": f"otp-{login_id}"},
        timeout=10,
    ))


def resend_code(message_id, login_id, attempt):
    return checked(client.post(
        f"{BASE}/v1/sms/resend/{message_id}",
        json={},
        headers={"Idempotency-Key": f"resend-{login_id}-{attempt}"},
        timeout=10,
    ))


def verify_code(phone, code):
    return checked(client.post(
        f"{BASE}/v1/sms/verify",
        json={"phone": phone, "code": code},
        headers={"Idempotency-Key": f"verify-{uuid.uuid5(uuid.NAMESPACE_OID, phone + code)}"},
        timeout=10,
    ))
Enter fullscreen mode Exit fullscreen mode

Cancelling a pending send is the same shape against the cancel route, keyed on the message id you got back from the first call, and it's what you fire when the user edits their number. Note what isn't in this file: the cooldown. Attempt counters, the per-country allowlist and the daily ceiling sit in a wrapper above these three functions, in your own storage, because they encode a business decision about which traffic you are willing to pay for. Put them in the vendor and you'll discover their granularity isn't yours.

These are plain HTTP calls with a bearer token, so the Node.js version is the same three requests through fetch with no SDK to install — which is also why a low-code app builder with a generic HTTP action can drive the same flow without a server in the middle. Keep the key server-side either way.

The option I rejected: email as the primary code channel

I wanted email to work here, because it costs less to abuse-proof and it dodges the SIM-swap conversation. It doesn't survive the latency requirement. A login code has to land in under about ten seconds to feel like part of the login screen, and email delivery time is a negotiation with the receiving provider — shared IP reputation, DKIM alignment per RFC 6376, whether a bulk sender rule decides today is the day you go to the spam folder. I've watched the same template arrive in 2 seconds and in 4 minutes on the same day.

The API surface makes it worse rather than better. Infrai's email side lacks a hosted OTP primitive, so you'd generate, hash and compare codes yourself, and its scheduled sends have no cancel counterpart the way SMS does. Twilio, Vonage and Plivo don't offer email verification codes as a managed product either — you'd be gluing Resend, Postmark or SES to your own code table and owning the whole lifecycle.

There is a real case for it, and I've shipped it twice. Stick with email when corporate policy blocks SMS on managed devices, when you're doing account recovery where a few minutes of delay is acceptable and the audit trail matters more than speed, or as the third channel after two SMS attempts have gone unanswered.

And the honest counterweight to this whole article: NIST treats SMS as a restricted out-of-band authenticator, so for admin accounts and anything holding money, TOTP or passkeys beat both channels. SMS OTP is the pragmatic default for consumer signup because it converts, not because it's the strongest factor you can offer. Design the resend and cancel paths well, cap the spend, and treat it as what it is.

References

Top comments (0)