Short answer: for a beginner US/EU SaaS, choose an SMS OTP API with explicit suppression controls and a queryable delivery record, then make the record—not the SMS callback—the compliance artifact. For a marketplace that sends an order receipt after payment settles, this keeps the authentication decision and the receipt trail separate but correlatable.
The cheapest-looking route is rarely the least expensive operationally. A missed suppression event can send a code to a recycled number; an unrecorded status poll can leave an auditor asking whether a receipt was sent, queued, or rejected. I optimize for evidence first, latency second, and token or message cost third.
Ship the evidence.
What should a beginner 2FA login stack record before sending an order receipt?
Treat the flow as two state machines. The login state machine creates an OTP challenge, sends it once, suppresses duplicate sends while the challenge is live, and records verification. The order state machine waits for a settled payment, renders the receipt, and records the communication request. A shared correlation id links them without making a receipt depend on a user's phone carrier.
For every SMS attempt, persist the challenge id, a salted hash of the OTP, destination region (US or EU), consent version, suppression decision, provider request id, and timestamps. Do not store the OTP itself. For every receipt, persist payment id, settlement timestamp, template version, message id, and the final delivery state. That is enough to answer “what happened?” without retaining more personal data than the investigation needs.
I keep a suppression window in our database because a browser retry is normal. The API call is idempotent from our side: the same challengeId returns the existing attempt until it expires. The provider's delivery webhook is useful, but it is not the sole source of truth; a poll repairs gaps after a deploy or a lost callback.
A small TypeScript implementation for suppression and polling
The following adapter uses generic HTTPS endpoints so the application code stays replaceable. The exact paths belong in configuration and must match the selected API's published discovery document.
type SmsState = "queued" | "sent" | "delivered" | "failed" | "suppressed";
interface SmsGateway {
send(input: { to: string; body: string; idempotencyKey: string }): Promise<{ id: string }>;
status(id: string): Promise<{ state: SmsState; updatedAt: string }>;
}
const SUPPRESSION_MS = 90_000;
export async function requestOtp(
gateway: SmsGateway,
input: { challengeId: string; phone: string; now: number; lastSentAt?: number },
) {
if (input.lastSentAt && input.now - input.lastSentAt < SUPPRESSION_MS) {
return { state: "suppressed" as const, challengeId: input.challengeId };
}
const otp = crypto.randomInt(100000, 1000000).toString();
const result = await gateway.send({
to: input.phone,
body: `Your login code is ${otp}. It expires in 10 minutes.`,
idempotencyKey: `otp:${input.challengeId}`,
});
// Store only a salted OTP hash and this provider id in the durable record.
await saveChallenge({
id: input.challengeId,
otpHash: await hashOtp(otp),
providerMessageId: result.id,
sentAt: input.now,
state: "sent",
});
return { state: "sent" as const, providerMessageId: result.id };
}
export async function pollUntilFinal(gateway: SmsGateway, id: string) {
for (let attempt = 0; attempt < 6; attempt += 1) {
const current = await gateway.status(id);
await appendDeliveryEvent(id, current);
if (["delivered", "failed", "suppressed"].includes(current.state)) return current;
await new Promise((resolve) => setTimeout(resolve, 2 ** attempt * 1_000));
}
return { state: "queued" as const, updatedAt: new Date().toISOString() };
}
The loop records every observed transition, not just the final answer. In practice, that makes a receipt audit readable: payment settled at one timestamp, the OTP challenge was verified, suppression prevented a duplicate, and the receipt message reached delivered or remained queued with a next-check time. The six attempts are a policy choice, not a guarantee of delivery.
Where SMS OTP stacks fail in US and EU SaaS
Carrier filtering is only one failure mode. The more expensive mistakes are local: treating a timeout as a failure and sending twice, accepting a code after its expiry, or letting a support operator export raw phone numbers into a ticket. Rate-limit by account, phone hash, and network signal; cap verification attempts; and make the lockout response intentionally vague so it does not reveal whether an account exists.
Regional rules also change the evidence you need. Keep consent and purpose tied to the message template, record the lawful-basis or policy reference used by your product, and set retention per region instead of copying a single global value. Apple Mail Privacy Protection is about email open measurement, not SMS delivery, yet the underlying lesson applies: a client-side signal is not proof that a message was received.
I initially assumed a webhook was sufficient. Then a test deployment dropped one callback while the provider still showed a terminal state; the missing event became a compliance gap, not a user-visible outage. Your mileage may vary with carrier and region, so measure p95 send latency and terminal-state age by country before changing retry policy.
That incident changed the shape of the data model. We now write an append-only event for the request, each poll observation, and each webhook observation, with the source and received-at time on every row. A reconciliation worker compares the latest provider state with the local state and emits a new event when they differ; it never edits history. When payment settlement and login verification arrive out of order, a correlation id lets the receipt job wait without replaying the OTP. During a support review, an operator can filter by payment id, see the exact template version, and explain why a second send was suppressed. The extra rows are boring, but they are cheaper than asking an engineer to infer intent from application logs after retention has expired.
Choosing an API without locking the application
Keep a tiny gateway interface like the one above and put vendor-specific authentication, route names, and status mapping behind it. Evaluate candidates against documented idempotency semantics, suppression support, status retention, webhook signing, data residency options, and exportable audit events. A beginner can ship this in one service, then move polling to a worker when volume justifies it.
Do not select on unit price alone. A low per-message quote does not cover the engineer-hours spent reconstructing delivery evidence or handling duplicate OTPs. The right choice is the one whose records your team can explain six months later, even after you swap the SMS provider.
The catch is that this pattern is not suitable when you need voice fallback, high-volume marketing consent management, or a provider-managed identity product with a full risk engine. Stick with a specialized identity service when those controls are a hard requirement; keep the gateway abstraction only if portability still matters.
Before launch, run a table-top exercise: payment settles twice, the browser retries twice, a webhook arrives out of order, and a user changes from a US to an EU number. If each event produces one durable record and one explainable decision, the stack is ready.
Top comments (0)