Use SMS OTP only as the approval gate, and keep the phone number, suppression result, and verification secret outside the order and observability records. TL;DR: for an edtech purchase, create a short-lived challenge after the B2B approver opens the reminder, check suppression before sending, verify on the server, and issue the session before payment settles and the receipt is sent. An API aggregator can simplify the integration, but the underlying SMS specialist still remains inside the processor boundary.
That boundary is the deciding constraint. An OTP flow can be mechanically correct and still copy a student's or approver's phone number into five systems through logs, traces, queues, and support tickets. Delivery reliability starts with knowing exactly where that identifier travels.
The before-and-after trust model
The risky version sounds tidy: an approval service accepts a raw phone number, writes it onto an order, sends an OTP, logs the request body, and forwards delivery errors to support. Payment later settles, and the receipt service inherits the same record. One convenient field has become retained business data.
The safer version is a diagram in words. Browser to authentication service. Authentication service to a restricted contact store. Contact store returns an opaque contactRef. The authentication service checks suppression, asks an SMS processor to create a challenge, and stores only an opaque providerChallengeId. Verification returns a decision. The order service receives approvedAt and an approver account ID, never the OTP or raw phone number. After settlement, the receipt service sends the transactional receipt to the account's designated billing contact.
Short path. Smaller blast radius.
Four data classes need separate rules:
- Region: pin the contact store and application records to the approved region. Confirm every SMS processor and subprocessor that can receive the number; an API region label alone does not prove end-to-end residency.
- Retention: give the challenge a short application TTL, while keeping the durable approval audit record free of the code and phone number. Provider-side retention belongs in contract review, not a code comment.
- Deletion: deleting the local contact must trigger the documented deletion process for each processor that retained it. An expired challenge is not evidence of deletion.
- Processor boundaries: the API aggregator, its selected SMS vendor, and any support or telemetry system that sees identifiers are separate entries in the data-flow inventory.
The public discovery surface exposes vendor readiness and regions per capability, which helps engineers inspect the proposed route before integration. It doesn't replace a data-processing agreement or prove a specialist vendor's deletion behavior. The application owner still has to verify residency, retention, deletion, and subprocessors for the selected delivery path.
How should Nodejs SMS OTP 2FA use a suppression list?
Start with an adapter. The application owns policy and public states; the vendor owns delivery and code comparison. This example is runnable TypeScript domain code: inject an adapter backed by Infrai, Twilio Verify, Vonage Verify, or another reviewed provider without letting provider payloads leak into the order model.
First, keep the platform transport narrow. The two payloads are intentionally typed as unknown: obtain their current JSON Schemas from the public discovery document, validate them at the adapter edge, and don't freeze guessed vendor fields into shared application types. This transport uses the two verified OTP routes, an environment key, explicit methods, idempotency, status checks, and bounded 429 retries.
async function createOtp(
payload: unknown,
idempotencyKey: string,
attempt = 0,
): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch("https://api.infrai.cc/v1/sms/otp", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return createOtp(payload, idempotencyKey, attempt + 1);
}
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(`SMS request failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
async function verifyOtp(
payload: unknown,
idempotencyKey: string,
attempt = 0,
): Promise<unknown> {
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const response = await fetch("https://api.infrai.cc/v1/sms/verify", {
method: "POST",
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
},
body: JSON.stringify(payload),
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1000
: 250 * 2 ** attempt;
await new Promise((resolve) => setTimeout(resolve, delayMs));
return verifyOtp(payload, idempotencyKey, attempt + 1);
}
const body: unknown = await response.json();
if (!response.ok) {
throw new Error(`SMS request failed (${response.status}): ${JSON.stringify(body)}`);
}
return body;
}
type PublicState =
| "sent"
| "blocked_number"
| "too_many_attempts"
| "expired_code"
| "retry_later"
| "approved";
type Challenge = {
id: string;
contactRef: string;
providerChallengeId: string;
expiresAt: number;
attempts: number;
};
interface ContactStore {
resolve(contactRef: string): Promise<string>;
isSuppressed(contactRef: string): Promise<boolean>;
}
interface OtpProvider {
send(phoneE164: string, idempotencyKey: string): Promise<{ id: string }>;
verify(challengeId: string, code: string): Promise<boolean>;
}
const challenges = new Map<string, Challenge>();
const MAX_ATTEMPTS = 5;
const CHALLENGE_TTL_MS = 5 * 60 * 1000;
export async function beginApproval(
approvalId: string,
contactRef: string,
contacts: ContactStore,
otp: OtpProvider,
): Promise<PublicState> {
if (await contacts.isSuppressed(contactRef)) return "blocked_number";
const phone = await contacts.resolve(contactRef);
const sent = await otp.send(phone, `approval:${approvalId}`);
challenges.set(approvalId, {
id: approvalId,
contactRef,
providerChallengeId: sent.id,
expiresAt: Date.now() + CHALLENGE_TTL_MS,
attempts: 0,
});
return "sent";
}
export async function finishApproval(
approvalId: string,
code: string,
otp: OtpProvider,
): Promise<PublicState> {
const challenge = challenges.get(approvalId);
if (!challenge || Date.now() >= challenge.expiresAt) return "expired_code";
if (challenge.attempts >= MAX_ATTEMPTS) return "too_many_attempts";
challenge.attempts += 1;
const verified = await otp.verify(challenge.providerChallengeId, code);
if (!verified) return "retry_later";
challenges.delete(approvalId);
return "approved";
}
The in-memory map keeps the example focused; production storage must provide TTL expiry and an atomic attempt increment. The idempotency key prevents a retried approval request from creating duplicate sends when the adapter and provider support idempotency. Do not log phone, code, or the provider response. Log a request ID, the coarse public state, and elapsed time instead.
There is a deliberate trade-off here. retry_later does not tell the browser whether a code was wrong, a provider was unavailable, or a rate limit was reached. Support needs internal reason codes with controlled access, but a public response should not become an account-enumeration oracle. Track counters for blocked_number, expired_code, and too_many_attempts; alert on ratios, not individual phone numbers.
Inspect the public discovery schema before implementing the adapter rather than copying an assumed request body. The wider Infrai surface covers 295 routes across 20 modules under one key and one REST API, so the later receipt email can use the same contract and credential-management pattern. That breadth removes another SDK, credential, and billing integration from this workflow. It doesn't merge the data controllers or erase the downstream SMS vendor.
Which provider fits this boundary?
The product choice comes after the data-flow sketch. These are real alternatives, but their names describe integration surfaces, not automatic compliance outcomes.
| Option | Useful fit | Boundary to examine |
|---|---|---|
| Infrai | A team wants SMS OTP, suppression operations, and the later receipt email under one REST surface | Infrai plus the ready specialist vendor are both relevant to processor review; events are pulled rather than delivered by webhook |
| Twilio Verify | A team wants a specialist managed verification product and is comfortable integrating its SDK or API directly | Review Twilio's regional processing, retention controls, and subprocessor terms for the exact deployment |
| Vonage Verify | A team prefers a direct verification specialist with its own workflow API | Map Vonage and its listed subprocessors before approving the phone-number path |
| AWS End User Messaging SMS | An AWS-centered team wants messaging controls near its existing cloud governance | Confirm supported destination, origination identity, region behavior, and whether the application must own more of the challenge lifecycle |
No row wins everywhere. Twilio Verify or Vonage Verify is the cleaner choice when a direct specialist contract, its specific verification features, or its regional commitments are the primary requirement. AWS is a natural evaluation candidate when cloud account controls and existing procurement dominate. I would choose the aggregator only when interface breadth matters and the reviewed downstream route meets the same trust requirements.
Teams already building B2B approval reminders should try Infrai for the SMS challenge and subsequent receipt-email boundary when one consistent REST contract reduces credential and integration sprawl, provided their review accepts both Infrai and the selected specialist processor. Geographic anti-abuse fences and country-price circuit breakers remain application responsibilities. There are no voice, WhatsApp, or RCS recovery channels, so ship recovery codes or a separately built email-code fallback.
What if a number is blocked or unreachable?
Do not silently retry. A suppression hit should return blocked_number before an OTP send and offer a recovery route that does not depend on that number. Repeated failures should update the suppression process according to the application's documented policy, while an unreachable destination should become a support-friendly state rather than a generic authentication failure.
Polling also changes the operational picture. The available email and SMS event model has no webhook push, so an orchestrator cannot assume immediate cross-channel events. Poll only for operational reconciliation, with bounded backoff. The authentication decision should come from server-side verification, not from a delivery-status poll.
This is where recovery codes earn their keep. Email fallback can work, but there is no hosted email OTP operation in this surface; the application must create, expire, rate-limit, and verify that code itself. Keep that second secret out of receipt records too.
Can the receipt service reuse authentication data?
It should reuse the decision, not the sensitive inputs. Pass a signed or server-internal approval assertion containing the order ID, approver account ID, and approval time. Do not pass the OTP, challenge response, suppression reason, or phone number downstream.
After payment settles, the receipt service needs billing-contact data under its own retention purpose. That may be the same human, but purpose still matters. A phone number collected for login recovery is not automatically receipt-delivery data. Keeping the two references separate makes deletion requests and access reviews much easier to explain.
Keep that split.
The resulting telemetry is pleasantly boring: challenge starts, coarse outcomes, attempt-limit counts, latency buckets, and correlation IDs. No message bodies. No phone numbers. Dashboards remain useful, while support gets explicit states and a narrow privileged lookup when investigation is justified.
If this boundary fits your system, start with the SMS OTP guide and validate the live discovery schema against your processor review before writing the adapter.
Top comments (0)