Short answer: for a B2B SaaS receipt portal, keep the SMS OTP resend deadline, attempt count, and challenge status in the backend; let the Next.js button display the server's retryAfterSeconds, and instrument every state transition without logging the phone number or code.
| Pick this approach | When it fits | Reliability burden you still own |
|---|---|---|
| Managed verification service | The team wants the provider to own code generation and SMS delivery integration | Application idempotency, UI countdown, correlation, access policy, and provider-independent monitoring |
| SMS transport plus your own challenge store | Product rules need a custom challenge lifecycle | Code security, expiry, resend limits, race handling, delivery callbacks, and on-call runbooks |
| Existing identity platform | Phone login is already part of a broader identity program | Receipt authorization, event correlation, and the boundary between identity and order data |
The first option is the least complex default for most teams. The third is usually the cleanest when identity is already centralized. The custom path can be right, but only when its extra control pays for its much larger operational surface.
This is a delivery-reliability decision, not a button-animation decision.
Which verification path fits receipt access after payment settles?
A receipt workflow has two messages with different jobs. The SMS proves control of a phone number before the customer opens the portal; the receipt itself records a settled payment and is normally delivered through a separate channel. Keep those events separate in the data model. A successful OTP challenge may authorize receipt access, but it must not manufacture, settle, or resend an order. Payment state remains the source of truth.
Pick managed verification when a small team needs a narrow authentication boundary and doesn't want code generation inside the application. Keep an internal challenge ID anyway. It lets logs, metrics, and support tools describe one attempt without exposing a phone number. The provider response should be translated into your own tiny state vocabulary such as pending, verified, expired, and locked; the rest of the app should not depend on transport-specific labels.
Pick an existing identity platform when the same buyer already signs in to invoices, subscriptions, and account settings. Creating a second phone identity inside the receipt feature produces two lockout policies and two audit trails. That's hard to explain during an incident. Route the successful identity assertion into receipt authorization, then check that the authenticated subject may read the requested order.
Pick a custom challenge store only when rules such as tenant-specific risk limits or a required transport abstraction justify owning the security lifecycle. The catch is substantial: a team must review code generation, hashing, expiration, retries, concurrency, abuse controls, telemetry retention, and recovery. It is not suitable when the real requirement is merely “show a resend button.”
How should backend countdowns control phone verification retries?
Treat the countdown as a projection of server state. Diagram in words: browser asks for a challenge; backend locks the phone-keyed record; backend either creates a send attempt or returns the existing cooldown; transport accepts the message; backend records the transition; browser renders the returned deadline. On refresh, on a second tab, or after a slow mobile connection, the browser asks again and gets the same authoritative answer.
Don't let setInterval grant permission to send. It only updates pixels.
The following TypeScript sketches the application boundary. The policy values — a 30-second resend interval, a five-minute challenge lifetime, and five verification attempts — are example choices, not universal recommendations. Tune them against your abuse model, support load, and delivery evidence. The important part is that the store performs beginOrResume atomically, so two requests cannot both decide they are the first send.
import { createHash, randomUUID } from "node:crypto";
type ChallengeState = "pending" | "verified" | "expired" | "locked";
type ChallengeView = {
challengeId: string;
state: ChallengeState;
retryAfterSeconds: number;
expiresAt: string;
};
type BeginResult = ChallengeView & {
shouldSend: boolean;
destination: string;
};
interface ChallengeStore {
beginOrResume(input: {
destinationKey: string;
requestKey: string;
now: Date;
resendAfterSeconds: number;
lifetimeSeconds: number;
maxAttempts: number;
}): Promise<BeginResult>;
markAccepted(challengeId: string, transportMessageId: string): Promise<void>;
}
interface SmsTransport {
sendCode(input: {
destination: string;
challengeId: string;
}): Promise<{ messageId: string }>;
}
const destinationKey = (phone: string) =>
createHash("sha256").update(phone).digest("hex");
export async function requestOtp(
request: Request,
store: ChallengeStore,
sms: SmsTransport,
): Promise<Response> {
const { phone } = (await request.json()) as { phone?: string };
if (!phone) {
return Response.json({ code: "PHONE_REQUIRED" }, { status: 400 });
}
const requestKey = request.headers.get("Idempotency-Key") ?? randomUUID();
const challenge = await store.beginOrResume({
destinationKey: destinationKey(phone),
requestKey,
now: new Date(),
resendAfterSeconds: 30,
lifetimeSeconds: 300,
maxAttempts: 5,
});
if (challenge.shouldSend) {
const accepted = await sms.sendCode({
destination: challenge.destination,
challengeId: challenge.challengeId,
});
await store.markAccepted(challenge.challengeId, accepted.messageId);
}
return Response.json({
challengeId: challenge.challengeId,
state: challenge.state,
retryAfterSeconds: challenge.retryAfterSeconds,
expiresAt: challenge.expiresAt,
});
}
There are two useful keys here. Idempotency-Key collapses a browser retry of the same intent. The destination hash enforces a cooldown across fresh requests and multiple tabs. Neither belongs in a process-local map: serverless instances restart and scale independently, so the atomic decision needs a shared store with conditional writes or a transaction. Store the normalized destination encrypted if later delivery needs it, and keep the hash as the lookup key. The raw OTP should never appear in logs.
The frontend is deliberately boring. It uses an absolute deadline derived from the response, recalculates remaining time from the clock, and asks the backend again when the user resends. A backgrounded tab may pause timers, so decrementing a counter once per tick is less accurate than recomputing it.
type OtpResponse = {
challengeId: string;
state: "pending" | "verified" | "expired" | "locked";
retryAfterSeconds: number;
expiresAt: string;
};
export async function startOtp(phone: string): Promise<OtpResponse> {
const response = await fetch("/api/auth/otp/request", {
method: "POST",
headers: {
"Content-Type": "application/json",
"Idempotency-Key": crypto.randomUUID(),
},
body: JSON.stringify({ phone }),
});
if (!response.ok) {
throw new Error(`OTP_REQUEST_REJECTED_${response.status}`);
}
return (await response.json()) as OtpResponse;
}
export const retryDeadline = (result: OtpResponse): number =>
Date.now() + result.retryAfterSeconds * 1_000;
export const secondsRemaining = (deadline: number): number =>
Math.max(0, Math.ceil((deadline - Date.now()) / 1_000));
One subtle choice matters: generate a new idempotency key for a deliberate resend, but reuse the same key when retrying a request whose response was lost. Without that distinction, a network retry can become an extra message, while an intentional resend can be mistaken for an old request. The UI can keep the key beside the pending request until it receives a response.
Walk through the ugly sequence because it exposes why the boundary matters. A buyer submits a phone number, the backend commits challenge c_17 with a future retry deadline, and the transport accepts the message. The response to the browser disappears on a weak connection. The buyer taps again before the page learns about c_17; at almost the same moment, a second tab makes its own request. If each server instance trusts a local timer, three messages can leave and three independent countdowns can appear. With an atomic shared record, the retry carrying the original idempotency key receives the existing view, while the second tab finds the same destination cooldown and also receives that view. Neither creates a send. Once the deadline passes, one deliberate action may advance the attempt and establish a new deadline; a concurrent action observes that update. The log now tells a coherent story: one accepted transport attempt, two cooldown reuses, one challenge ID. Support can distinguish “the response was lost” from “the message was requested three times,” and an alert does not inflate send demand because of browser retries. This example is intentionally specific, but it isn't a production incident or a benchmark. It is a concurrency case the implementation and test suite should prove before deployment.
What should logs, metrics, and alerts say about delivery reliability?
Start with a structured event for each state transition. Useful fields include challenge_id, request_id, tenant_id, region_class, event, attempt, transport, and latency_ms. A region class such as US or EU can support operational slicing without storing the destination in telemetry. Never attach the OTP, full phone number, message body, or receipt contents. Retention and access controls should match the sensitivity of an authentication trail.
Count requests by outcome: accepted for delivery, cooldown reused, input rejected, challenge verified, challenge expired, and challenge locked. Measure the time from request acceptance to verification, but don't label transport acceptance as user delivery. Those are different events. If callbacks are available, record them as later transitions correlated by the internal challenge ID and transport message ID.
Alert on ratios and sustained changes, not raw traffic alone. A jump in cooldown reuse may mean an impatient UI, delayed messages, or abuse; it doesn't identify the cause by itself. A fall in verified-to-requested ratio can also reflect user abandonment. I’m not sure which signal will be most predictive for a particular audience and carrier mix — a baseline from production traffic, segmented by region class and transport, is what resolves that uncertainty.
This is where a crisp before/after helps. Before: “SMS is down” based on support tickets. After: “verification completion fell for EU-class destinations after a deployment, while transport acceptance stayed flat and cooldown reuse rose.” The second statement narrows the investigation without pretending that one metric proves delivery.
For the receipt job, carry a separate order_id correlation into the authorization audit only after verification succeeds. Do not put it in SMS transport metadata unless the transport genuinely needs it. The clean timeline is payment settled, receipt created, verification challenged, verification succeeded, receipt viewed. Each event has its own owner and timestamp.
Test the state machine, not the timer animation
Unit tests should freeze time and exercise the boundary values: the request immediately before the resend deadline, exactly at the deadline, and immediately after it. Add concurrent tests in which two requests for the same destination reach beginOrResume together. Exactly one may return shouldSend: true. Then test a lost response: repeating the same idempotency key must return the same challenge view without creating another send attempt.
Keep going. Test expiry during verification, exhaustion of the attempt limit, normalization of equivalent phone input, and authorization for an order belonging to another tenant. In an integration environment, use a fake SmsTransport that records accepted messages and lets the test drive callbacks in different orders. That gives deterministic coverage without teaching the test suite to wait for a real handset.
Deployment needs one compatibility rule: old and new application versions must understand the same stored challenge states during a rolling release. Add new states in a backward-compatible phase before code begins writing them. Dashboard the new transition alongside the old one, then remove obsolete handling later.
US and EU operation adds policy questions beyond code: where identity data is stored, who may inspect it, how consent and abuse complaints are handled, and whether regional traffic needs separate transports. Those choices require legal and security review for the actual business. A country prefix alone is not enough evidence for a person's location or the law that applies.
Limits and decision rule
Use managed verification when delivery integration is necessary but custom challenge mechanics are not a product advantage. Stick with an established identity platform when receipt access is one permission inside an existing account. Build the challenge lifecycle yourself only when documented product rules require that control and the team can own its security and on-call cost.
SMS OTP is not suitable as the only protection for high-risk administrative actions, and the receipt portal should offer a recovery path for users who lose access to a number. The resend countdown reduces accidental duplication and basic hammering; it does not establish message delivery, prevent every abuse pattern, or replace authorization checks on the order.
Email receipt observability has different traps. DKIM defines a signing mechanism for email domains, while Apple Mail Privacy Protection limits what a sender can infer from remote-content loading. Those facts are useful reminders that authentication evidence, transport acceptance, and user engagement are separate signals. Don't collapse them into one “delivered” metric.
Ship the state machine first. Make the countdown a view of it. Then let telemetry show where the real delivery path needs attention.
References
- RFC 6376, DomainKeys Identified Mail (DKIM): https://datatracker.ietf.org/doc/html/rfc6376
- Apple, Mail Privacy Protection guide: https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
Top comments (0)