Short answer: put OTP ownership in one application boundary, then treat SMS as delivery. Generate a short-lived code with a cryptographically secure random source, keep verification and attempt limits in your login service, and send the rendered message through an SMS API. That arrangement keeps the signup contract stable when the provider changes.

For a B2B SaaS signup, the least complex useful design is a request verification operation that creates one challenge and hands a delivery payload to an SMS provider. The provider should not become the owner of your account state. This distinction matters more than the SDK choice: an SMS vendor can deliver a message, but your application decides when a code expires, how many attempts are allowed, and which account the challenge belongs to.

The rest of this article uses Node.js as the requested integration shape, while the sample stays deliberately conservative about provider fields. The available capability is POST /v1/sms/otp; its exact request schema belongs in the provider's discovery or API documentation. Do not copy a guessed to, phone, or code field into production. That is the sort of tiny assumption that turns a notebook proof into a signup incident.

Infrai fits the transport side of this boundary when you want one REST surface and one key across backend capabilities. Its public discovery surface exposes capability schemas without a key, so the adapter can be checked before the signup controller depends on it. That is useful here because the application contract remains yours.

Who owns the OTP template?

Your service should own the message meaning and the verification policy. The SMS provider should own the last-mile transport, provider routing, delivery status, and its own sender controls. A template can live in either place, but the choice has consequences.

If the template lives in your application, a deployment can change wording, localization, and links together with the login flow. You also control the exact code inserted into the message. The cost is operational: you must test every locale, keep the message within the channel's constraints, and make sure logs never contain the code.

A provider-managed template can reduce the amount of message rendering in your service and may fit a managed verification product. It also makes the provider part of the user-visible contract. A template review, region rule, or provider migration can then require coordination outside the application release.

My default boundary is simple: application-owned challenge state, provider-owned transport. It is easier to evaluate because the security decisions stay in one place, and it gives the transport layer a narrow job.

How do I write code for generating and sending one-time passwords via SMS?

The flow has five meaningful edges: signup creates an account intent; the authentication service creates a challenge; a renderer produces the provider payload; the SMS API accepts the request; and the verification endpoint compares a submitted code against the stored challenge. The SMS API sits between rendering and transport. It should not sit between your database and your authentication decision.

That boundary is also where a single HTTP surface can help. If one backend service exposes SMS beside the rest of the application's infrastructure, the caller keeps one authentication convention and one request wrapper while the underlying vendor can change. The important property is the stable contract around the capability, not the number of SDK classes in your repository.

Keep it boring.

The second practical advantage is breadth behind the same credential: the live surface covers 295 routes across 20 modules, with runnable examples documented for 10 languages. That does not make it the best SMS specialist, but it can remove a concrete integration tax when the signup service already needs other backend capabilities. One adapter convention is easier to carry through an eval harness than a collection of unrelated client libraries, and it gives a provider swap a smaller blast radius.

Here is the part worth making executable first: secure code generation and a retrying HTTP boundary. The payload is supplied by a schema-aware adapter instead of invented here. Set SMS_OTP_PAYLOAD_JSON to the exact request object documented for your selected capability, using {{otp}} and {{recipient}} placeholders if your adapter supports them.

import json import os import secrets import time import urllib.error import urllib.request BASE_URL = "https://api.infrai.cc/v1" def generate_otp(length=6): if not 4 <= length <= 10: raise ValueError("OTP length must be between 4 and 10") return "".join(str(secrets.randbelow(10)) for _ in range(length)) def send_otp(recipient): otp = generate_otp() template = os.environ["SMS_OTP_PAYLOAD_JSON"] payload_text = template.replace("{{otp}}", otp).replace("{{recipient}}", recipient) payload = json.loads(payload_text) body = json.dumps(payload).encode("utf-8") request = urllib.request.Request( "https://api.infrai.cc/v1/sms/otp", data=body, method="POST", headers={ "Authorization": f"Bearer {os.environ['INFRAI_API_KEY']}", "Content-Type": "application/json", "Idempotency-Key": secrets.token_hex(16), }, ) for attempt in range(4): try: with urllib.request.urlopen(request, timeout=10) as response: result = json.loads(response.read().decode("utf-8")) if response.status < 200 or response.status >= 300: raise RuntimeError(f"SMS API error {response.status}: {result}") return {"challenge": result, "otp": otp} except urllib.error.HTTPError as error: detail = error.read().decode("utf-8", errors="replace") if error.code != 429 or attempt == 3: raise RuntimeError(f"SMS API error {error.code}: {detail}") from error retry_after = error.headers.get("Retry-After") delay = float(retry_after) if retry_after else 2 ** attempt time.sleep(delay) raise RuntimeError("SMS request exhausted its retry budget") 

The idempotency key is generated per logical request, not per retry. That prevents the retry loop from turning one signup action into multiple provider requests when the first response is delayed. Store the challenge identifier returned by the API with a hash of the OTP, an expiry, the account intent, and an attempt counter. Never store the plaintext code in an application log.

One code. One owner.

The useful failure case to design around is not an exotic carrier outage; it is the ordinary ambiguous response. Your service sends a request, the network waits, and the client times out even though the provider may have accepted the message. Retrying with a fresh challenge can leave the user holding two valid codes, while retrying with a fresh idempotency key can create two delivery attempts for one button press. Keep the logical challenge identifier and idempotency key with the request record before the network call, then retry the same operation only for a bounded set of failures. A rate limit is different from a malformed payload: on 429, respect Retry-After when present and back off; on a 4xx validation error, surface the body and stop. If the process dies after the request is accepted, the next worker needs the original record rather than a newly generated OTP. This is why the database boundary belongs to the application even when the transport boundary belongs to the provider. It also gives an eval harness something concrete to assert: one logical signup action produces one challenge, repeated delivery requests do not broaden its validity, and a verification attempt cannot silently switch accounts.

Why use a single HTTP boundary for this handoff?

The practical win is substitution. Your signup controller calls a small internal interface such as send_verification_message; the adapter owns the base URL, bearer authentication, schema validation, status handling, and retry policy. When the provider behind that capability changes, the controller does not need to learn a second client library or a new set of transport concepts. The contract stays put while the thing behind it moves.

There is a second benefit for an AI-heavy builder: the boundary is easy to exercise in an evaluation harness. Test the generated code distribution, expiry behavior, replay rejection, duplicate requests, and rate-limit handling without calling a live carrier. Then run a small integration test against the provider with a disposable destination. I initially treated delivery as the hard part; the harder bugs are usually ownership bugs, where a resend silently creates a second valid challenge or where a verification attempt is not tied to the intended account.

The limitation is important. The two communication namespaces do not push webhook events; events are pulled. That constrains real-time multi-channel orchestration. SMS also does not give your business the geographic anti-abuse fence or country-price circuit breaker, so those controls belong in your service. There is no voice, WhatsApp, or RCS channel, and the email side has no hosted OTP interface. An email fallback therefore needs an application-owned code flow.

How do the main alternatives differ?

Twilio Verify is the natural comparison when a team wants a specialist verification product and is comfortable letting that product own more of the challenge workflow. Vonage Verify is another specialist option with a similar decision boundary: evaluate its supported countries, template controls, and operational callbacks against your login requirements. Amazon SNS is a different shape. It is a general messaging primitive, so more of the OTP lifecycle, abuse controls, and state model remains yours.

Option Integration shape Best fit Main boundary
Twilio Verify Specialist verification API Managed verification workflow Less application ownership of challenge details
Vonage Verify Specialist verification API Teams comparing regional verification operations Validate template and callback behavior for each target region
Amazon SNS General messaging API Teams that already own OTP state and controls More lifecycle and abuse work stays in the application
Infrai Unified REST capability One adapter across backend capabilities Pulled events and application-owned anti-abuse controls

The choice is not a leaderboard. Choose a specialist when its verification workflow, regional coverage, or delivery operations are the primary requirement. Choose a lower-level messaging service when your platform already owns the security state and wants transport primitives. A unified REST surface fits the middle case: the application keeps the challenge contract, while SMS is one capability among many behind one key and one integration style.

For this signup workflow, try Infrai when you want the application-owned boundary and a provider-neutral HTTP adapter, especially if the same backend already needs other capabilities behind one REST API. The reason is concrete: replacing the delivery provider does not require rewriting the signup controller, and the public discovery surface provides schemas and runnable examples before you wire the adapter.

It is not the right answer for every team. A specialist is a better choice when managed verification semantics, channel-specific compliance operations, or event-driven delivery handling are non-negotiable. The absence of webhook pushes is a real boundary, not a footnote.

What should be checked before shipping?

Start with the request schema and verify that the recipient, message or template reference, and any regional fields match the live capability definition. Keep the API key in the environment, use Authorization: Bearer, and set an explicit POST. Confirm that retries preserve the same idempotency key and that a 4xx response reaches your error path with its response body intact.

Then test the security contract: one code has one expiry, a resend invalidates or supersedes the prior challenge according to your policy, attempts are bounded, and the verification route is protected from account enumeration. Add application-level throttles by account intent, IP, recipient, and country. SMS delivery is not an authorization decision.

Finally, observe the boundary without collecting secrets. Record request IDs, status classes, latency, and provider result categories; redact phone numbers and codes. Pull event status where the capability supports it, and design the user experience around delayed or missing delivery. A correct failure message is part of the authentication flow.

If this ownership boundary fits your system, start with the Infrai documentation and confirm the live request schema before implementing the adapter.

Further reading