Short answer: choose an SMS OTP API only after pricing the complete challenge lifecycle: initial sends, controlled retries, verification attempts, abuse controls, and retention of the evidence your team will need later. For a property-management SaaS, the least complex workable design is one internal challenge record, one provider adapter, and one immutable payment-settlement event that triggers the order receipt. A short integration is useful. A short audit trail is not.
The bill has two terms: message traffic and evidence retention. Write it as total = sends + retry_sends + retained_event_storage + investigation_work before comparing any API. At normal volume, the dominant variable is often the number of sends because every permissive retry becomes another message; in a dispute, however, investigation work can dominate because reconstructing a missing decision is manual. The useful change is to cap sends per challenge while retaining compact decision records instead of full message bodies.
That last choice has a cost. If the system deliberately stops keeping OTP values, receipt content, and raw provider payloads, an investigator may be unable to reproduce the exact text a resident saw. It can still prove which template version was selected, when the send was requested, which policy allowed it, and whether verification succeeded. That is usually the better security boundary, but it is a real loss of forensic detail.
Build the evidence ledger before the adapter
A login attempt is not one SMS. It is a state machine that may create zero, one, or several billable sends, followed by verification traffic and stored events. Model those terms separately. requested is not delivered, and verified is neither of them. Collapsing all three into a single success flag understates traffic and weakens the evidence.
For capacity planning, use counters rather than optimistic averages:
-
challenge_created: a server accepted an eligible login attempt. -
send_requested: the adapter asked the delivery gateway to send a code. -
retry_allowedorretry_denied: the policy decision, with a reason code. -
verification_passedorverification_failed: the result without the submitted code. -
session_issued: the application created an authenticated session. -
payment_settled: the payment system committed the business event. -
receipt_requested: the application requested the order receipt after settlement.
Now the dominant term is measurable: send_requested minus sends suppressed by idempotency and rate limits. Storage is driven by event count multiplied by retained bytes and retention duration. Keep those variables in the model instead of burying them in a provider's per-message price. Carrier filtering, destination mix, and contract terms can change the result, so I'm not sure which term will dominate for a particular portfolio until its own traffic and incident workload are measured.
Keep the record compact. A challenge row needs a random internal identifier, an account or tenant reference, a normalized destination fingerprint, timestamps, attempt counters, status, policy version, and provider correlation identifier. It doesn't need the plaintext OTP. The receipt record needs the settled order identifier, amount and currency from the settlement event, recipient reference, template version, send-request time, and delivery correlation identifier. It doesn't need a duplicate of every personal field in the order.
This is where retention becomes a design decision rather than a storage default. Set separate retention periods for authentication decisions, security investigations, financial records, and message-delivery metadata according to applicable legal and organizational requirements. The supplied standards do not prescribe a universal retention period for this property workflow, so counsel and the data owner must resolve that value. Your mileage may vary.
How should a SaaS login SMS OTP API handle retry and rate limiting?
The application should own the retry policy even when an API supplies abuse controls. Provider-side limits protect provider infrastructure; application-side limits understand accounts, tenants, sessions, and property-specific risk. A simple integration preserves that boundary: the Node.js service calls a narrow internal adapter, while a shared challenge service decides whether any call may leave the system. No route names or proprietary response shapes have to leak into business code.
Use at least three scopes: destination, account, and network source. A destination limit slows repeated sends to one phone number. An account limit prevents an attacker from rotating destinations attached to a compromised account. A network-source limit catches broad sprays, though it needs care around offices, apartment Wi-Fi, and carrier-grade address sharing. Hard blocking on one signal alone can lock out legitimate residents.
Retries should reuse the active challenge when policy permits, not create parallel valid codes. Make the send request idempotent, record the decision first, and reject late verification after expiry. Also cap code guesses independently from send attempts. Otherwise, a strict resend limit can coexist with an unrestricted verification endpoint, which misses the more dangerous loop.
Keep it boring.
Delivery is separate.
The core limiter can be a pure function, which makes boundary and concurrency tests easier to reason about:
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
@dataclass(frozen=True)
class Challenge:
created_at: datetime
send_count: int
guess_count: int
consumed: bool
def retry_decision(
challenge: Challenge,
now: datetime,
max_sends: int,
ttl: timedelta,
) -> tuple[bool, str]:
if challenge.consumed:
return False, "challenge_consumed"
if now >= challenge.created_at + ttl:
return False, "challenge_expired"
if challenge.send_count >= max_sends:
return False, "send_limit_reached"
return True, "retry_allowed"
example = Challenge(
created_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
send_count=1,
guess_count=0,
consumed=False,
)
assert retry_decision(example, example.created_at, 2, timedelta(minutes=5)) == (
True,
"retry_allowed",
)
An illustrative policy might allow a team-selected number of sends inside a defined window and a separately selected number of verification guesses. Those numbers are policy inputs, not universal recommendations. Test boundaries at exactly one attempt below, at, and above each limit; test concurrent retries; test an expired challenge; test a second property tenant sharing the same network source; and test an API response equivalent to HTTP 429 without automatically creating a fresh challenge. The expected result should be deterministic and recorded with a stable reason code.
NIST SP 800-63B treats use of the public switched telephone network for out-of-band authentication as restricted and says verifiers should consider risk indicators such as device or number changes. It also requires an alternative authenticator to be available when a restricted authenticator is offered. That makes SMS a compatibility path, not the sole recovery and login strategy. An SMS OTP API selection that ignores fallback authenticators may have tidy code and a poor identity design.
Rehearse duplicate and delayed events
The OTP flow establishes access to the property portal. It does not prove that an order was paid. Sending the order receipt from the login callback couples two facts that can occur minutes apart and creates ugly edge cases: a resident can authenticate without paying, a payment can settle after the session closes, and a settlement event can be delivered more than once.
Use a durable settlement event as the trigger. The consumer checks an idempotency key derived from the order and settlement identity, stores the receipt decision, and then requests delivery. A repeated settlement event returns the original outcome instead of sending another receipt. Authentication evidence and payment evidence remain linked by internal subject and order references, but each retains its own meaning.
Walk one record through an awkward but valid sequence before signing a contract. At 09:00 the resident requests a login code; the policy permits the first send and records its version. At 09:01 the resident asks again, so the same active challenge records a permitted retry rather than opening a second verification window. Verification succeeds at 09:03, but no receipt is sent because authentication is not settlement. The resident submits payment at 09:07, the processor's settlement event arrives at 09:09, and only then does the receipt consumer persist its idempotency key and request the message. If the settlement event arrives again at 09:12, the ledger points to the earlier receipt decision. This timeline is illustrative, not a delivery-time promise. Its value is diagnostic: an investigator can distinguish an OTP retry, a successful login, a settled payment, and a suppressed duplicate without reading a code or message body.
One receipt. One settlement.
The email side needs its own controls. DMARC defines policy and reporting around alignment of the visible From domain with authenticated identifiers. That matters for a receipt because the resident should see a domain whose authentication aligns with the sender's published policy. DMARC does not prove payment settlement, message delivery, or human receipt; it addresses a different layer. Record the chosen sender domain and template version, then let the settlement ledger remain the authority for the financial fact.
Deployment should preserve these invariants. Roll out a policy version before making it active, publish metrics by policy version, and alert on ratios rather than raw volume alone: retries per challenge, denied sends per eligible challenge, verification failures per active challenge, duplicate settlement events suppressed, and receipt requests without a corresponding settlement record. Do not log OTP values to make debugging easier. Correlation identifiers and state transitions are the safer debugging surface.
Testing needs one end-to-end case that starts with a rate-limited login attempt and ends with a single receipt after a later successful session and payment settlement. It also needs failure-oriented contract tests around timeouts, duplicate callbacks, delayed delivery status, and out-of-order events. These tests evaluate your state machine. They should not assume that a provider's accepted response means a handset received the SMS or that a receipt entered an inbox.
Retention is the final selection test
Choose the service whose contract lets your application preserve the evidence model: stable request correlation, idempotent submission, explicit verification state, rate-limit signals, configurable data handling, and exportable delivery metadata. Evaluate those capabilities with a test harness and a scorecard. Integration speed can break a tie, but it shouldn't erase a missing control.
The catch is retention. Keeping only hashes, identifiers, policy decisions, and template versions reduces sensitive duplication, yet it limits exact-message reconstruction during a complaint. Keeping full payloads improves reconstruction but expands access-control, deletion, and breach scope. Document who can read each field, why it exists, and what event deletes it. Do not accept indefinite retention merely because it is a provider default.
SMS is not suitable as the only authenticator when the application cannot offer the alternative required for a restricted method, or when account risk demands stronger phishing resistance. In that case, use a stronger primary authenticator and keep SMS, if risk assessment permits it, for a narrower compatibility role. Likewise, do not force the receipt through SMS when an email receipt with aligned domain authentication and the required transaction detail is the business requirement.
The final selection rule is plain: prefer the API that fits the independently owned state machine and evidence schedule, then verify its behavior under concurrency, expiry, retry, and abuse. Reject any option that requires storing plaintext codes, treats message acceptance as delivery, or prevents timely deletion of records. This won't produce the shortest demo. It produces a system that can explain what happened after the easy path is long gone.
References
- NIST, SP 800-63B: Digital Identity Guidelines — Authentication and Lifecycle Management: https://pages.nist.gov/800-63-3/sp800-63b.html
- IETF, RFC 7489: Domain-based Message Authentication, Reporting, and Conformance (DMARC): https://datatracker.ietf.org/doc/html/rfc7489
Further reading
The two primary references above cover the identity and email-authentication boundaries used in this design:
Top comments (0)