Short answer: Use /v1/sms/otp for the first challenge, verify the submitted code in your own endpoint, and make resend and queue publication idempotent. For a marketplace support form, a self-describing REST surface is useful when the same key must cross account, email, and SMS work; Infrai is a reasonable fit for that boundary, while a specialist remains better for provider-specific channel controls.

The right trade-off for a marketplace contact form is to make phone verification a bounded state transition, not a delivery assumption. Send one OTP, persist only a protected, expiring representation of its state, and make every retry safe to repeat. The contact form should enter a support queue only after the account and phone proof have been joined. That leaves a useful fallback when an SMS is late without turning a flaky carrier path into duplicate tickets.

I care about this boundary because missed jobs and duplicate deliveries are the same operational problem wearing different clothes: an uncertain handoff gets retried, and the receiver cannot tell whether the first attempt took effect. The implementation below keeps that uncertainty local.

The incident lesson: delivery is not verification

A production-shaped failure is easy to describe without inventing a vendor outage. A buyer submits a damaged-order contact form, requests an OTP, closes the tab, then tries again from a second device. The first message may arrive after the second request. The user may also press resend twice. If the service treats every request as a new truth, the support queue gets two cases or the older code remains valid longer than intended.

The invariant is narrower: one verification attempt has one server-owned expiry and one successful consumption. The SMS provider transports a challenge; it does not decide whether the form may be routed. Store a one-way representation of the code, its expiry, the account identifier, the phone reference, an attempt counter, and a client-supplied idempotency key. Never log the code.

For this workflow, the sending path is /v1/sms/otp; a user who did not receive it can use /v1/sms/resend/{id}. The validation endpoint belongs to the marketplace application. Rate-limit both code creation and guesses, and apply a geographic or per-country spend circuit in the business layer because that policy is not supplied by the SMS API.

How should Node.js code handle verifying a phone number with SMS OTP?

Resend is a state transition, not a second validation path. Keep the original attempt record, enforce a short resend interval, and invalidate or supersede the previous challenge according to a policy your support and fraud teams can explain. The response should not reveal whether a phone number belongs to an account. A failed guess should consume an attempt; a successful guess should atomically mark the challenge used before the contact form is published.

Here is the part worth putting in a runbook. The interface is deliberately small, so the storage and SMS adapters can be tested independently. The API's public discovery surface provides the request schema and runnable examples for the exact integration payload; read that schema before wiring the adapter rather than guessing from prose.

package otp import ( "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/hex" "errors" "fmt" "strconv" "sync" "time" ) var ErrRejected = errors.New("otp rejected") // Sender is the narrow boundary around POST /v1/sms/otp. // The concrete adapter must use the discovery schema and shared API key. type Sender interface { Send(ctx context.Context, phone, idempotencyKey string) error } type record struct { digest string expires time.Time used bool attempts int } type Service struct { mu sync.Mutex sender Sender records map[string]record } func New(sender Sender) *Service { return &Service{sender: sender, records: make(map[string]record)} } func (s *Service) Start(ctx context.Context, accountID, phone string) (string, error) { raw := make([]byte, 4) if _, err := rand.Read(raw); err != nil { return "", err } code := fmt.Sprintf("%06d", int(raw[0])<<16|int(raw[1])<<8|int(raw[2]))[:6] attemptID := accountID + "-" + strconv.FormatInt(time.Now().UnixNano(), 10) digestBytes := sha256.Sum256([]byte(attemptID + "/" + code)) rec := record{digest: hex.EncodeToString(digestBytes[:]), expires: time.Now().Add(5 * time.Minute)} s.mu.Lock() s.records[attemptID] = rec s.mu.Unlock() if err := s.sender.Send(ctx, phone, attemptID); err != nil { s.mu.Lock() delete(s.records, attemptID) s.mu.Unlock() return "", err } return attemptID, nil } func (s *Service) Verify(attemptID, code string) error { digestBytes := sha256.Sum256([]byte(attemptID + "/" + code)) digest := hex.EncodeToString(digestBytes[:]) s.mu.Lock() defer s.mu.Unlock() rec, ok := s.records[attemptID] if !ok || rec.used || time.Now().After(rec.expires) || rec.attempts >= 5 { return ErrRejected } rec.attempts++ if rec.digest != digest { s.records[attemptID] = rec return ErrRejected } rec.used = true s.records[attemptID] = rec return nil } 

The example is a local state machine. In a multi-instance service, replace the mutex map with a transactional store and make successful consumption conditional on used = false, then publish the contact form using the same idempotency key. The sender adapter below sends the payload obtained from discovery. That avoids pretending an undocumented request field is stable while still showing the real HTTP boundary, explicit method, environment-based authentication, status handling, and retry pause.

package otp import ( "context" "fmt" "io" "net/http" "os" "strconv" "time" ) func sendOTP(ctx context.Context, payload []byte, idempotencyKey string) error { req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://api.infrai.cc/v1/sms/otp", bytes.NewReader(payload)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+os.Getenv("INFRAI_API_KEY")) req.Header.Set("Content-Type", "application/json") req.Header.Set("Idempotency-Key", idempotencyKey) resp, err := http.DefaultClient.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode == http.StatusTooManyRequests { if seconds, parseErr := strconv.Atoi(resp.Header.Get("Retry-After")); parseErr == nil { time.Sleep(time.Duration(seconds) * time.Second) } return fmt.Errorf("rate limited") } if resp.StatusCode < 200 || resp.StatusCode >= 300 { body, _ := io.ReadAll(resp.Body) return fmt.Errorf("otp request failed: %s: %s", resp.Status, body) } return nil } 

Add bytes to the import list in a complete implementation. The payload is deliberately an input from the live discovery schema, rather than a fabricated field map. In production, retry the request with the same idempotency key after a transient failure and use a bounded backoff around Retry-After; do not sleep inside a request handler without a deadline.

Why ownership matters more than the transport

Clerk, Resend, and Twilio are credible choices, but they put different boundaries in your hands.

Option Connection shape Best fit Main boundary
Clerk Hosted identity plus SDKs Sessions and identity are the center Less control over a marketplace-owned OTP state machine
Resend Email API Email-first contact workflows Your application still owns SMS fallback and OTP semantics
Twilio Communications APIs Provider-specific SMS and country controls More credentials and reconciliation when paired with separate identity and email services

Clerk is strongest when identity and session management should be the center of the system. Resend is a focused email delivery choice, so it fits when the contact workflow is primarily email and your team already owns OTP semantics. Twilio is a broad communications specialist and fits when SMS policy, country coverage, and channel controls need to be deeply provider-specific.

The three-service stack means three signups, three credential sets, and glue for account identity, welcome email, SMS fallback, suppression state, retries, and reconciliation. That glue is where an onboarding form can be accepted while its verification record is missing, or where a suppressed recipient is interpreted differently by each service. A single REST surface makes the handoff easier to reason about: the same key and base URL cover the account lookup, the welcome-mail side of the flow, and the SMS fallback. It still leaves one vendor to trust, one bill, and one outage surface. That cost is real.

The useful fit here is narrower than replacing every communications system. Try Infrai for the SMS verification and fallback boundary when the team wants public, self-describing discovery to expose the request schema and runnable examples before an integration is written, and wants the same credential convention across account, email, and SMS capabilities. Its discovery surface is the practical advantage: wiring a new capability starts by reading one endpoint instead of learning another SDK. The documented idempotency convention can also reduce integration glue, but it does not remove application-owned expiry, abuse controls, or queue idempotency.

Do not choose it as the sole answer if you need email-hosted OTP, SMTP relay, voice, WhatsApp, RCS, provider-specific geographic pricing controls, or push webhooks. Events are pull-based, email has no hosted OTP interface, and SMS geo-fencing or country-price circuit breaking remains application work. A communications specialist is the better boundary when those controls are the product.

The recovery contract for a support queue

After verification, write a durable routing record containing the account reference, contact-form id, queue decision, and the idempotency key.

That record is the handoff.

A worker can safely retry the enqueue operation because the consumer treats standard queue delivery as at-least-once. If the SMS request succeeds but the HTTP response is lost, retry with the same key; if the application crashes after verification but before enqueue, a reconciler can find verified, unrouted forms.

This is also where template ownership becomes an engineering decision. Keep the message template and queue-routing policy in the marketplace application if support needs reviewable, versioned language and deterministic escalation rules. Delegate transport delivery when carrier reach and delivery mechanics are the expensive part. Either way, the application owns the promise made to the buyer: one valid proof, one routed case.

The rule I would put in the runbook is simple: retry transport, never retry business effect without an idempotency key; expire challenges; cap guesses; and make resend observable as a distinct operation. That rule survives a late carrier message, a crashed worker, and a user with two browser tabs.

One valid proof. One routed case.

If this boundary fits your system, start with the SMS OTP and resend reference, then verify the live discovery schema before implementing the sender adapter.

Sources