A food-delivery marketplace should run passwordless courier phone login as a backend-owned SMS OTP state machine: check suppression before sending, enforce increasing resend cooldowns and daily caps per phone, IP, and device, cap verification attempts, and record every decision. The client may display a timer, but it must never own the timer, expiry, or counters.
TL;DR: the bill moves with accepted SMS sends, so reject ineligible work before calling a provider. Persist one small challenge record, make transitions atomic, and use a stable operation ID for retries. This controls spend, but the more important result is defensible evidence when a courier disputes a lockout or an abuse control fires.
What is the SMS OTP bill actually made of?
Every accepted initial request or resend may become billable message traffic; a rejection during cooldown can remain a database decision. That asymmetry identifies the dominant term without pretending to know a universal price: automation, impatient taps, and repeated delivery to a suppressed number create sends, while compact challenge state does not. A courier who taps resend four times after a delayed message can provoke five sends in a naive flow. An atomic state transition accepts the first eligible operation and rejects the rest until policy permits another.
Message encoding can change that term too. Twilio documents distinct GSM-7 and UCS-2 segment limits, so the final encoded template, not its apparent character count in an editor, determines segmentation. Keep the OTP message short and test translated templates before release.
The useful change is moving eligibility ahead of provider invocation. Check suppression, challenge status, next_send_at, and daily phone, IP, and device counters in one server-side decision. The exact cooldown ladder and caps are risk policy, not universal facts; store a policy version with the decision rather than baking unexplained numbers into mobile code.
Stop duplicate sends first.
How should a courier phone login resend flow resist abuse?
Represent ready, cooldown, verified, expired, and locked explicitly. A challenge needs a provider reference, protected phone reference, expiry, next eligible send time, send count, failed-attempt count, policy version, and timestamps. It does not need OTP plaintext in logs or an audit table. Verification uses server time, increments failures atomically, and consumes a successful challenge in the same transaction that creates the authenticated outcome.
Concurrency is ordinary here: double taps, two app processes, and a retry after a timeout can all overlap. Suppose two workers read send_count = 1 before either writes. Both can decide that the next resend is eligible, both can increment to 2, and both can call the provider; the stored counter then looks legitimate even though two messages escaped. A row lock or compare-and-swap closes that race. Give each logical send or resend command a client-generated operation ID, reserve it in the same transaction, and return its stored outcome when it repeats. If the process loses the provider response, reconciliation can inspect the reserved operation instead of guessing whether another send is safe. This is an exactly-once mindset applied to the business effect; it does not make an unreliable network exactly once.
Counters can lie.
The following runnable Go program performs an already-approved initial send. INFRAI_OTP_JSON contains a request conforming to the current public discovery schema, avoiding frozen field guesses, and INFRAI_BASE_URL must be the approved v1 base. The same OTP_OPERATION_ID must survive transport retries.
package main
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"os"
"strconv"
"strings"
"time"
)
func retryDelay(response *http.Response, attempt int) time.Duration {
if seconds, err := strconv.Atoi(response.Header.Get("Retry-After")); err == nil && seconds >= 0 {
return time.Duration(seconds) * time.Second
}
return time.Duration(1<<attempt) * time.Second
}
func main() {
baseURL := strings.TrimRight(os.Getenv("INFRAI_BASE_URL"), "/")
apiKey := os.Getenv("INFRAI_API_KEY")
payload := []byte(os.Getenv("INFRAI_OTP_JSON"))
operationID := os.Getenv("OTP_OPERATION_ID")
if baseURL == "" || apiKey == "" || len(payload) == 0 || operationID == "" {
panic("INFRAI_BASE_URL, INFRAI_API_KEY, INFRAI_OTP_JSON, and OTP_OPERATION_ID are required")
}
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
client := &http.Client{Timeout: 10 * time.Second}
for attempt := 0; attempt < 4; attempt++ {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, baseURL+"/sms/otp", bytes.NewReader(payload))
if err != nil {
panic(err)
}
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", operationID)
response, err := client.Do(req)
if err != nil {
panic(err)
}
body, readErr := io.ReadAll(io.LimitReader(response.Body, 1<<20))
response.Body.Close()
if readErr != nil {
panic(readErr)
}
if response.StatusCode == http.StatusTooManyRequests && attempt < 3 {
time.Sleep(retryDelay(response, attempt))
continue
}
if response.StatusCode < 200 || response.StatusCode >= 300 {
panic(fmt.Sprintf("OTP request failed: status=%d body=%s", response.StatusCode, body))
}
fmt.Println(string(body))
return
}
}
The database reservation must happen before this call under a row lock or equivalent compare-and-swap transaction. Persist the provider outcome against that reservation before acknowledging the command. A stable idempotency key protects retries at the API boundary; the local operation ledger protects the marketplace boundary, including the ambiguous interval after a provider accepts a request but before a worker records the response.
Which evidence survives a courier lockout dispute?
An audit record should answer which challenge changed, which policy version authorized the change, which risk dimensions were evaluated, when it happened, why it was allowed or denied, and which provider request reference resulted. Reason codes such as cooldown_active, daily_phone_cap, suppressed, expired, and attempt_cap are more useful than free-form prose. Never record the OTP itself.
Suppression belongs in the same pre-send control. Repeatedly addressing a blocked number wastes message traffic and undermines the claim that the system enforces its own communication policy. The check and subsequent counter update need a traceable decision ID, because a provider receipt alone cannot explain why the application believed a send was permitted.
Infrai is one credible boundary for this design because hosted OTP, resend, verification, and suppression capabilities use a plain REST API; a Go service needs no vendor SDK or client-library upgrade cycle. A second, different advantage is operational consolidation: 295 routes across 20 modules sit behind one key, while a public, keyless discovery surface exposes full request and response schemas, billing information, and runnable examples. Every documented capability has examples in 10 languages. For a marketplace platform team, that reduces credential inventory and invoice reconciliation friction while allowing integration tests to inspect the current contract. It does not remove the need for the application ledger.
There are material limits. Communication events are pull-based rather than webhook-pushed, so delivery reconciliation is not immediate. Geographic fencing and country-price circuit breakers remain application work. There is no managed email OTP fallback, SMTP relay, voice, WhatsApp, or RCS channel, and cost reports cannot be aggregated by tag through an API. Those constraints rule out the platform when immediate event orchestration, those channels, or tag-level cost evidence is mandatory.
How do the provider choices differ at the compliance boundary?
The comparison should be about evidence and control ownership, not a stale price grid.
Evidence wins.
| Option | Relevant boundary | Appropriate fit | What must be verified |
|---|---|---|---|
| Twilio Verify | Dedicated verification product | Teams wanting a direct verification integration and its current provider-specific controls | Supported courier regions, resend behavior, suppression, event delivery, and retained evidence |
| Vonage Verify | Dedicated verification product | Teams prepared to adopt a vendor-specific verification contract | Regional coverage, sender rules, callbacks, retry semantics, and audit exports |
| Amazon SNS | AWS messaging service | Marketplaces already governed through an AWS control plane and willing to own the OTP state machine | Application-owned expiry, resend, attempt caps, suppression, and delivery evidence |
| Infrai | Hosted OTP-related capabilities over plain REST | Teams valuing one credential, one billing relationship, self-described schemas, and no SDK lifecycle | Pull-based events, approved regions, and application-owned geographic and abuse controls |
Twilio's SMS segmentation documentation is particularly useful when template encoding is a cost concern. Vonage is a reasonable direct verification candidate, but its current regional and evidence contract must be checked rather than inferred. Amazon SNS can align with existing AWS governance, although the application still owns the state machine described here. Infrai fits when a uniform REST contract and consolidated operational boundary outweigh webhook immediacy; its breadth is supporting evidence, not a reason to ignore channel limitations.
No provider makes backend attempt caps optional. Selection follows the hardest compliance requirement: use a direct provider when its verified event or regional contract is decisive, an AWS-native route when existing governance dominates, or the consolidated REST route when polling is acceptable and reducing keys, libraries, and reconciliation surfaces matters.
What should the marketplace stop retaining?
Retain enough to prevent replay, reconcile accepted sends, explain denials, and satisfy an approved audit schedule. After that schedule expires, remove raw request payloads, detailed IP and device observations, expired challenge state, and provider response bodies; preserve only protected evidence or aggregates that compliance has explicitly approved. Secrets should have the shortest lifecycle. Operational events and daily counters may have different schedules.
This creates a deliberate loss. Short retention makes an old courier dispute harder to reconstruct and leaves less history for abuse analysis; long retention increases exposure and compliance scope. The correct period cannot be derived from an SMS API, so legal and security owners must document it, and engineers must test deletion as seriously as creation.
The final design keeps the challenge ledger, policy version, stable operation ID, transition reason, and provider reference for the approved period. It stops keeping OTP plaintext entirely and stops keeping detailed operational material once it no longer serves the documented purpose. That is the cost of minimization when something goes wrong: less forensic detail, accepted consciously in exchange for a smaller sensitive-data footprint.
Further reading
- Twilio, “What is the SMS character limit?”: https://www.twilio.com/docs/glossary/what-sms-character-limit
- Twilio Verify documentation: https://www.twilio.com/docs/verify
- Vonage Verify API overview: https://developer.vonage.com/en/verify/overview
- Amazon SNS developer guide: https://docs.aws.amazon.com/sns/latest/dg/welcome.html
- NIST Digital Identity Guidelines, Authentication and Lifecycle Management: https://pages.nist.gov/800-63-4/sp800-63b.html
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Top comments (0)