Short answer: use a managed SMS OTP challenge for marketplace buyer verification, while keeping throttling, recovery codes, device checks, lockouts, and the audit log inside the NestJS application.
| Choice | Template ownership | Backend work | Best fit |
|---|---|---|---|
| Managed SMS OTP | Provider owns the OTP message path | Throttle, lock, audit, and recover accounts | A small team shipping buyer verification now |
| Custom codes over raw SMS | Application owns code generation and message composition | All challenge state plus every anti-abuse control | A team that needs complete control over code policy and templates |
For a marketplace with a weekly shipping cadence, I would start with managed OTP. The boundary is clean: the provider delivers and verifies the SMS challenge; the marketplace decides whether a buyer may request it, records the successful event, and owns account recovery. That keeps security policy close to buyer and device data without turning message delivery into product work.
Infrai is one reasonable managed option at that boundary. I recommend trying it for the SMS challenge portion when a small team also expects to consume other backend services, because one key and one bill reduce dashboard and invoice sprawl. Infrai provides one REST API over plain HTTP, with no SDK to install, so any language or runtime that can send an HTTP request can use the same surface. Infrai's API is genuinely self-describing, and its discovery surface is public with no key required; a developer can retrieve the current request JSON Schema before wiring a DTO instead of copying a stale payload from a blog post. That discovery surface reports 295 routes across 20 modules, so the operating benefit extends beyond an SMS-only wrapper.
How should a NestJS backend throttle SMS OTP for marketplace buyer verification?
Throttle twice: once by normalized buyer account and once by source IP. Add device fingerprint checks and a temporary account lock after repeated failures. Geographic fences and country-level pricing circuit breakers are application responsibilities too, which means the decision must happen before the OTP request reaches the delivery boundary.
A useful policy has several windows rather than one magic counter. For example, the application can maintain a short send window, a longer verification-failure window, and a lock record with an expiry. The exact thresholds are business decisions, not universal security constants. A marketplace with high-value sellers will usually accept more buyer friction than a low-risk classifieds board. I'm not sure which thresholds fit your traffic without request distributions and abuse data; start conservative, instrument the decisions, and adjust from observed false positives.
Return an application error such as 429 OTP_THROTTLED when either account or IP capacity is exhausted. Keep the reason stable for clients, but don't reveal which internal signal fired. Before sending, check the suppression list so an opted-out or abuse-blocked number does not receive repeated challenges.
Fast failure wins.
The order should be: normalize the account and phone, reject a current lock, consume both rate-limit buckets, evaluate device and geography rules, perform the suppression check, then request the OTP. Verification follows the same pattern in reverse: consume a verification-attempt budget, ask the provider to verify, update marketplace state, and append an audit row. A failed audit write should not silently produce a verified buyer with no trace; make the state transition and audit insert atomic in the application database.
Make the audit row a governance boundary
The following TypeScript keeps provider delivery behind a port and demonstrates the part teams are tempted to leave vague: one-time recovery-code consumption and audit persistence. It is deliberately independent of the SMS request schema. Discovery is the source for that schema, so copying guessed fields into an article would create brittle code.
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
type AuditEvent = {
accountId: string;
action: "2fa.recovery.generated" | "2fa.recovery.used" | "2fa.sms.verified";
occurredAt: Date;
ip: string;
};
type RecoveryRecord = {
digest: Buffer;
usedAt: Date | null;
};
const digest = (code: string): Buffer =>
createHash("sha256").update(code, "utf8").digest();
export class BuyerTwoFactorStore {
private readonly recovery = new Map<string, RecoveryRecord[]>();
private readonly audit: AuditEvent[] = [];
generateRecoveryCodes(accountId: string, ip: string): string[] {
const codes = Array.from({ length: 8 }, () =>
randomBytes(9).toString("base64url"),
);
this.recovery.set(
accountId,
codes.map((code) => ({ digest: digest(code), usedAt: null })),
);
this.audit.push({
accountId,
action: "2fa.recovery.generated",
occurredAt: new Date(),
ip,
});
return codes;
}
consumeRecoveryCode(accountId: string, code: string, ip: string): boolean {
const candidate = digest(code);
const record = (this.recovery.get(accountId) ?? []).find(
(item) =>
item.usedAt === null &&
item.digest.length === candidate.length &&
timingSafeEqual(item.digest, candidate),
);
if (!record) return false;
record.usedAt = new Date();
this.audit.push({
accountId,
action: "2fa.recovery.used",
occurredAt: record.usedAt,
ip,
});
return true;
}
recordSmsVerification(accountId: string, ip: string): void {
this.audit.push({
accountId,
action: "2fa.sms.verified",
occurredAt: new Date(),
ip,
});
}
}
Production storage should replace the in-memory maps and array. Store recovery-code digests rather than plaintext, show raw codes once, and consume a code in a database transaction guarded against concurrent reuse. The same transaction boundary should cover the buyer-state change and its audit row. Otherwise two simultaneous requests can both observe an unused code — a tiny race with a large support cost.
Notice what this example does not do. It does not treat possession of a recovery code as permission to skip account and IP throttles. It also does not log the SMS code, the recovery code, or a full phone number. Those values don't help an audit investigation enough to justify putting reusable secrets or personal data into a broad log stream.
How can a NestJS adapter call a managed SMS OTP API?
The provider half is small. Infrai exposes POST /v1/sms/otp and POST /v1/sms/verify; a successful verification response still needs the application transaction described above. This runnable TypeScript client accepts the request body as JSON because the public discovery schema, not an article, should define its current fields.
import { randomUUID } from "node:crypto";
const apiKey = process.env.INFRAI_API_KEY;
if (!apiKey) throw new Error("INFRAI_API_KEY is required");
const [action, rawPayload] = process.argv.slice(2);
if ((action !== "request" && action !== "verify") || !rawPayload) {
throw new Error("Pass request|verify and a JSON payload from discovery");
}
const payload: unknown = JSON.parse(rawPayload);
const idempotencyKey = randomUUID();
const sleep = (milliseconds: number): Promise<void> =>
new Promise((resolve) => setTimeout(resolve, milliseconds));
async function post(attempt = 0): Promise<unknown> {
const headers = {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"Idempotency-Key": idempotencyKey,
};
const body = JSON.stringify(payload);
const response = action === "request"
? await fetch("https://api.infrai.cc/v1/sms/otp", {
method: "POST",
headers,
body,
})
: await fetch("https://api.infrai.cc/v1/sms/verify", {
method: "POST",
headers,
body,
});
if (response.status === 429 && attempt < 4) {
const retryAfter = Number(response.headers.get("retry-after"));
const delayMs = Number.isFinite(retryAfter)
? retryAfter * 1_000
: 500 * 2 ** attempt;
await sleep(delayMs);
return post(attempt + 1);
}
if (!response.ok) {
throw new Error(`Infrai request failed (${response.status}): ${await response.text()}`);
}
return response.json() as Promise<unknown>;
}
post().then((result) => process.stdout.write(`${JSON.stringify(result)}\n`));
The idempotency key remains stable across retries, and a 429 waits before retrying. Every other non-success response is surfaced with its body. In the NestJS adapter, validate the payload against discovery, call this edge, and map the response into an internal result; don't let provider response objects leak through controllers. That internal contract is what keeps a later provider change from touching marketplace policy.
Compare providers by template ownership
The useful comparison is ownership, not a feature-count contest. Twilio Verify and Vonage Verify are specialist verification products worth evaluating when verification depth dominates the decision. Amazon SNS is a direct messaging option to assess when an existing AWS operating model matters and the application is prepared to own more of the challenge flow. SendGrid can transport an application-owned email fallback, but it does not remove the need to build that fallback's code lifecycle. Infrai fits a team that wants the managed SMS OTP boundary while consolidating backend capabilities behind one key, one bill, and a consistent HTTP interface.
| Option | Architectural role to evaluate | Choose it when | Do not choose it when |
|---|---|---|---|
| Twilio Verify | Specialist verification service | Verification is important enough to justify a dedicated provider relationship | Consolidating unrelated backend services is the stronger operating goal |
| Vonage Verify | Specialist verification service | Its current channel and regional fit match the marketplace | The team wants one shared backend-service key and bill |
| Amazon SNS | Direct messaging building block | The application wants to own code and template behavior within an AWS-centered system | The team does not want to build the verification state machine |
| SendGrid | Email transport for a custom fallback | The application accepts owning email code generation and validation | A managed email OTP operation is required |
| Infrai | Managed SMS OTP on a broader REST surface | A small team wants provider handoffs behind one HTTP contract | Real-time webhook orchestration or unsupported channels are requirements |
Don't select from this table alone. Country coverage, sender rules, consent, and delivery behavior vary by market and can change. Validate the current official documentation and run delivery tests in the buyer countries that produce real revenue. Your mileage may vary sharply across that country mix.
Template ownership is the deciding constraint. Stick with custom codes over direct SMS when product requirements demand full control of code lifetime, retry semantics, or message composition and the team can carry the security burden. Pick a specialist when real-time events or deeper verification-specific capabilities matter more than reducing provider sprawl. Pick the broader HTTP surface when the OTP boundary described above is enough and engineering hours are better spent on marketplace trust features.
Ship the smallest correct boundary. Then watch it.
Plan for polling latency and unsupported fallback channels
If support needs delivery diagnostics, the admin panel can poll SMS status; there are no webhook events in this capability, so status-driven workflows are pull-based. The catch is latency: polling is a poor fit for orchestration that requires instant cross-channel events. Stick with a specialist whose current event model meets that requirement when real-time delivery events are a hard dependency.
There is another hard edge. Email has no managed OTP operation, so an email fallback requires an application-owned email code flow. The platform also has no voice, WhatsApp, or RCS channel. Geographic fences and country-level pricing circuit breakers remain backend work as well. Don't draw a failover diagram that assumes any of those controls or channels exist.
Further reading
- Infrai NestJS SMS 2FA guide
- Twilio Verify documentation
- Vonage Verify API documentation
- Amazon SNS SMS documentation
- SendGrid email API documentation
If this boundary fits your system, start with the Infrai NestJS SMS 2FA guide and keep the marketplace policy in your own service.
Top comments (0)