TL;DR
For a US/EU SaaS login, choose an SMS OTP API only after its start, rate-limit, retry, and code-verification results fit a small evidence ledger that cannot affect payment settlement or receipt delivery.
That ledger is the useful integration artifact. It tells a Node.js service what to record, a Python eval what to assert, and a security review what each result is allowed to prove. A quick start can be delightfully short while leaving those questions unanswered.
The experiment begins with data, not provider calls.
Start with an evidence ledger
Write down the facts each workflow may produce before evaluating an API. For an OTP flow, useful evidence includes a pseudonymous subject, a stable login-operation identifier, an opaque challenge identifier, the country cohort, a state transition, a policy version, and timing. The raw candidate and plain-text phone number don't belong in general application telemetry. For an order receipt, the source fact is that payment settled; its notification identity and processing state belong to the payment-notification workflow, not authentication.
Here is the ledger I would put beside the first notebook experiment:
| Evidence record | May establish | Must never establish | Sensitive field excluded |
|---|---|---|---|
| Challenge started | An OTP operation has a challenge | The person authenticated | Raw OTP candidate |
| Challenge limited | Policy declined a start for now | A new challenge should be created automatically | Plain-text phone number |
| Candidate accepted | The remote challenge accepted one submission | Payment status or receipt eligibility | Raw OTP candidate |
| Local access granted | The application completed one access transition | Message delivery | Provider response body |
| Payment settled | An order became eligible for receipt work | Login success | Authentication secret |
| Receipt recorded | One logical receipt record exists | Mailbox delivery or identity proof | OTP challenge identifier |
This table does more than document fields. It prevents a generic send_message() abstraction from erasing domain meaning. OTP traffic has a secret, expiry, guessing budget, and terminal verification decision. A receipt comes from a settled order and needs an idempotent notification identity. Both may eventually use communication infrastructure, but sharing a record shape, credential, or retry loop makes the permitted transitions harder to inspect.
Keep the ledger boring.
Where does the real integration cost appear?
The first invalid transition is “message accepted, therefore user authenticated.” Transport acknowledgement is not code verification. The second is “client response lost, therefore create another challenge.” A repeated start may represent the same login operation, so preserve its operation identity while the outcome is uncertain rather than silently producing parallel challenges.
The third mistake is subtler: “candidate accepted, therefore every concurrent request may grant access.” A remote acceptance and a local access grant are different facts. Local code must ensure that simultaneous submissions cannot produce multiple access transitions. Hidden retries make this worse. Never replay candidate verification behind the person's back; return a stable state and require an intentional next action.
Then there is payment. “Login completed, therefore the receipt worker may advance” is invalid, as is its reverse. Test this with an ugly interleaving: commit payment settlement, make receipt work available, repeat a login start after losing the client response, delay the receipt worker, and submit two plausible candidates concurrently. Next, deliver the settlement event again while authentication is delayed. The required results are one logical receipt record, at most one local access grant, and no cross-domain state change. A unit test around one SMS call won't expose that coupling.
An HTTP 429 creates another precise boundary. It is a limit decision for the attempted operation, not proof that a new operation should begin. If the remote service provides a retry delay, the transport layer can honor it for the same operation; otherwise, a capped backoff with jitter can govern that operation. An intentional request for another code is different and must pass the product's repeat-send policy. Those actions may look alike in logs unless the ledger gives them distinct names.
This is where “simple” becomes measurable. Count how many application states a candidate forces you to add, how many provider fields escape its boundary, and whether authentication decisions can be replayed without touching payment or receipt code. Setup steps are a weak proxy for that burden.
How can US/EU SaaS login OTP verification remain auditable?
Turn the security floor into named evidence rules. NIST SP 800-63B treats the public switched telephone network for out-of-band authentication as a restricted authenticator. A verifier-generated secret must contain at least six decimal digits, be accepted only once, and be rate-limited when it has less than 64 bits of entropy. The eval should therefore exercise a consumed challenge, an expired challenge, repeated failed submissions, a limited start, and simultaneous accepted-looking submissions. It should inspect the resulting state transitions, not the secret.
Limit evidence needs several scopes because each answers a different abuse question. Destination scope detects repeated starts aimed at one number. Account scope sees one identity rotate through destinations. Tenant scope contains one SaaS customer's activity. IP or device evidence may help, but shared networks make either a poor sole key. I'm not sure a universal threshold exists for all US and EU traffic; country-cohort distributions from the actual rollout would resolve more of that uncertainty than a global average.
Regional support should likewise be an eval dimension rather than a label copied from a product page. Record challenge starts, intentional repeat-send requests, limit decisions, verification outcomes, age at submission, and local access grants for every served country cohort. Your mileage may vary because the account and destination mix changes. Keep the event vocabulary stable so a policy-version change can be compared with the previous version without retaining the OTP itself.
Receipt evidence has its own standard boundary. DMARC defines domain-owner policy and reporting around email identifier-alignment failures. It does not prove authentication, payment settlement, or mailbox delivery. That distinction belongs in the ledger because an order receipt may use email while the login uses SMS, yet neither channel result is authority to manufacture the other's domain fact.
Short traces win.
This is also prompt-cost discipline applied to operations: retain the smallest versioned evidence needed to answer an eval question. More fields can increase retention and review work without improving the decision. A trace should explain which policy ran and which transition occurred; it should not become a warehouse of credentials and provider payloads.
Encode the evidence projection in Python
The focused example is a projector, not a delivery client. It converts domain events into deliberately small evidence records and refuses to mix authentication with receipt state. A Node.js web tier can emit the same language-neutral JSON shape; Python stays useful for the notebook and CI eval harness.
from dataclasses import asdict, dataclass
from enum import Enum
from typing import Any
class AuthState(str, Enum):
CHALLENGE_STARTED = "challenge_started"
CHALLENGE_LIMITED = "challenge_limited"
CANDIDATE_REJECTED = "candidate_rejected"
CANDIDATE_EXPIRED = "candidate_expired"
CANDIDATE_ACCEPTED = "candidate_accepted"
ACCESS_GRANTED = "access_granted"
@dataclass(frozen=True)
class AuthEvidence:
subject_id: str
operation_id: str
challenge_id: str
country_cohort: str
state: AuthState
policy_version: str
@dataclass(frozen=True)
class ReceiptEvidence:
order_id: str
settlement_id: str
notification_id: str
state: str = "receipt_recorded"
def project_auth(event: dict[str, Any]) -> dict[str, str]:
evidence = AuthEvidence(
subject_id=event["subject_id"],
operation_id=event["operation_id"],
challenge_id=event["challenge_id"],
country_cohort=event["country_cohort"],
state=AuthState(event["state"]),
policy_version=event["policy_version"],
)
projected = asdict(evidence)
projected["state"] = evidence.state.value
return projected
def project_receipt(event: dict[str, str]) -> dict[str, str]:
evidence = ReceiptEvidence(
order_id=event["order_id"],
settlement_id=event["settlement_id"],
notification_id=f'receipt:{event["settlement_id"]}',
)
return asdict(evidence)
Feed this projector synthetic outcomes first. Assert that raw code, phone number, payment amount, and provider body never appear in authentication evidence. Assert that receipt evidence has no challenge identifier. Then give each candidate implementation the same operation identities and declared outcomes, including a limit result and an expired challenge, and compare only the projected records.
It is intentionally incomplete as a messaging client. That's the point. Authentication headers, transport parsing, and provider-specific response fields live in the implementation under test; the rest of the application sees the ledger. If changing a candidate requires edits to the projector, payment handler, receipt worker, and database schema, the advertised simplicity has not survived contact with the system.
Make the API decision last
Adopt a managed SMS OTP API when it can satisfy the evidence contract, enforce the required verification policy, preserve a login operation through uncertain outcomes, and keep provider concepts inside one integration boundary. Reject a candidate for this SaaS design when code verification must be inferred from delivery, hidden retries consume verification attempts, or payment and receipt components must understand challenge state. The winner is the one that adds the least governance surface under the same tests, not the one with the shortest isolated demo.
The catch is that this design adds an evidence schema, policy versions, and cross-domain tests before production traffic exists. That is real work. For a throwaway internal notebook with no production access decision, customer phone data, or payment path, a deterministic fake is enough. For a team required to own code generation, carrier routing, or every retention decision, a managed verification API is not suitable; use a self-operated component and accept responsibility for generation, single-use enforcement, guessing defense, delivery integration, abuse response, and continuing evaluation.
SMS is also the wrong primary authenticator when the threat model requires phishing resistance. Choose an authenticator that meets that requirement for administrator access, sensitive payment actions, and recovery. The smallest integration is the one that meets the actual assurance policy; an easy connection to the wrong authenticator is still the wrong result.
Before adopting the choice, run one bounded rollout for each served country cohort and inspect outliers rather than declaring a universal benchmark. Repeat the eval after any policy, integration, cohort, or recovery-flow change. The artifact worth keeping is the evidence contract — compact, versioned, and unable to turn an OTP result into a payment fact.
Top comments (0)