Short answer: model an SMS OTP resend as a new delivery attempt on one active challenge, then keep delivery evidence separate from the support-queue decision. The Node.js API should return a small, stable error contract; a durable worker should classify uncertain delivery without guessing; and the routing record should preserve the rule version that selected the queue.
The deciding constraint is compliance evidence. A customer-support contact form may contain an account reference, a phone number, an urgency signal, and free-form text. If the verification flow creates a new challenge after every timeout, an operator cannot tell which code was valid when the form was routed. The implementation below uses Go for the state and worker examples, as required by this runbook's code standard; the same boundaries apply to a Node.js service.
The first rule is state ownership.
The second is evidence ownership.
What failure state does a support form create?
Only the delivery attempt should change. Keep one active challenge with an opaque ID, a purpose such as support_contact_verification, an expiry time, a verification status, and a bounded attempt count. Store a hash of the OTP rather than the code itself. Keep the routing decision in a separate record.
That separation prevents a common operational mistake: treating a delivery timeout as proof that the code was invalid. It is not. A timeout means the service lacks a confirmed result from the delivery boundary. An invalid-code submission is a verification failure. They need different states, different metrics, and different operator actions. The extra state is deliberately inconvenient: it requires a durable record, reconciliation work, and a dashboard that can show pending outcomes instead of flattening them into success or failure.
The resend request should be safe to repeat. Derive its idempotency key from the challenge and its next attempt number, not from the phone number and not from a random value created after a client timeout. A shared phone number can belong to more than one person, while a random post-timeout key turns one business action into two sends. Consider a client that submits attempt 3, loses the response, and submits again. The second request must find attempt 3, not invent attempt 4. If the queue has already handed attempt 3 to the delivery adapter, the service can report an existing or unknown state; it cannot safely infer that a second message is required. That small distinction is what keeps a browser retry from becoming a duplicate code, and it gives an incident reviewer a finite timeline: request, attempt, adapter result, and final verification outcome.
Keep the lifetime fixed. A resend may create another attempt for the active challenge, but it must not silently extend the challenge expiry, consume the challenge, create a second support case, or change the eventual queue. If the challenge is expired, consumed, locked, or over its attempt limit, return a terminal result and begin a new verification flow.
How should a support system handle SMS OTP delivery errors?
The HTTP handler should validate the request, load the challenge, enforce policy, and enqueue work. It should not let a provider-specific response become the public API. Normalize adapter results inside the worker and retain the transport detail only under the access and retention controls that apply to support data.
| Public result | Meaning | Caller action |
|---|---|---|
accepted |
A new attempt is queued, or the same attempt is already queued | Wait for the next permitted action |
too_many_requests |
The resend window or attempt limit is active | Show a retry time without sending again |
not_resendable |
The challenge is expired, consumed, or locked | Start a new verification flow |
delivery_unknown |
The adapter cannot prove the outcome | Wait or use an approved fallback |
invalid_request |
Required fields or formats are wrong | Correct the request; do not retry unchanged |
Here is the state transition I want to see in a code review. CreateAttempt must be protected by a uniqueness constraint on the challenge ID and attempt number. The queue entry and attempt record should be committed together through an outbox or equivalent transaction boundary, so a process exit cannot leave an accepted response with no durable work.
package otp
import (
"context"
"errors"
"strconv"
"time"
)
type AttemptStatus string
const (
AttemptQueued AttemptStatus = "queued"
AttemptUnknown AttemptStatus = "unknown"
)
var ErrNotResendable = errors.New("challenge is not resendable")
type Challenge struct {
ID string
Purpose string
ExpiresAt time.Time
Consumed bool
Locked bool
NextAttempt int
}
type Attempt struct {
ID string
ChallengeID string
AttemptNumber int
Status AttemptStatus
IdempotencyKey string
}
type ResendCommand struct {
ChallengeID string
RequestID string
Now time.Time
}
type Store interface {
GetChallenge(context.Context, string) (Challenge, error)
CreateAttempt(context.Context, string, int, string, string, time.Time) (Attempt, bool, error)
}
type JobQueue interface {
Enqueue(context.Context, Attempt) error
}
func QueueResend(ctx context.Context, cmd ResendCommand, store Store, queue JobQueue) (Attempt, error) {
challenge, err := store.GetChallenge(ctx, cmd.ChallengeID)
if err != nil {
return Attempt{}, err
}
if challenge.Purpose != "support_contact_verification" ||
challenge.ExpiresAt.Before(cmd.Now) || challenge.Consumed || challenge.Locked {
return Attempt{}, ErrNotResendable
}
key := challenge.ID + "/" + strconv.Itoa(challenge.NextAttempt)
attempt, created, err := store.CreateAttempt(
ctx, challenge.ID, challenge.NextAttempt, key, cmd.RequestID, cmd.Now,
)
if err != nil {
return Attempt{}, err
}
if created {
if err := queue.Enqueue(ctx, attempt); err != nil {
return Attempt{}, err
}
}
return attempt, nil
}
The boolean returned by CreateAttempt is important. A client may repeat the same request after losing the response, and a queue may redeliver the same job. Both paths should find the existing attempt instead of allocating another one. The adapter still needs its own idempotency behavior or a delivery-side deduplication strategy; the application key alone cannot prove what happened after a network timeout.
What does an uncertain delivery result mean?
Classify the worker result before deciding whether to retry. A known transient transport failure can use bounded exponential backoff with jitter. An invalid destination, rejected policy, or authentication failure is terminal. A timeout is usually unknown: the request may have reached the delivery boundary even though the response did not return.
Unknown is a real state.
Do not immediately let the browser call resend again. That creates the duplicate-message path we are trying to control. Return delivery_unknown to the caller, record the correlation ID and attempt ID, and expose an approved fallback or a new-flow instruction according to the account-access policy. The worker can reconcile the attempt later without pretending that a handset displayed the message.
The internal event should retain the normalized class, adapter status, response timing, and request identifiers. Avoid placing the raw phone number or form text in routine logs. A support request and a delivery record can become sensitive when joined, even when each field looks harmless in isolation.
The API contract should not ask the UI to parse transport text. Provider wording changes, and it isn't a useful interface for a caller that only needs to know whether to wait, correct input, or start again.
Which evidence proves the form reached the right queue?
Record routing as its own append-only event after verification succeeds. At minimum, the event should answer five questions: which challenge was used, which attempt was accepted, which rule version made the decision, which queue received the case, and which service request caused the change. Include a timestamp, event type, subject reference, challenge reference, attempt number, queue, rule version, outcome, and correlation ID. Do not include the OTP.
For example, a form can carry priority=account_access and locale=en-US. The routing event can record rule_version=2026-10-31.1 and queue=account-access. The exact policy is an organizational choice; the evidence requirement is not. If the routing rule changes tomorrow, the historical event should still explain yesterday's destination without being rewritten.
Delivery evidence and routing evidence answer different questions. Delivery evidence says an attempt was queued, sent, rejected, or became unknown. Routing evidence says a verified form was assigned to a queue. Combining them makes a carrier delay look like a routing failure and slows an incident review.
This architecture is not a universal answer. A low-risk contact form that does not need phone verification may be better served by a direct email link, because the challenge store, outbox, worker, and audit log add operational surface. For account access, that cost buys a reconstructable decision; for a simpler workflow, it may be needless machinery. The limitation is operational: durable attempts and reconciliation require ownership, retention rules, and on-call attention. The trade-off is justified only when proving the verification and routing decision matters more than keeping the workflow small.
package audit
import (
"errors"
"time"
)
var ErrIncompleteAudit = errors.New("routing event is incomplete")
type RoutingEvent struct {
OccurredAt time.Time
EventID string
RequestID string
ChallengeID string
AttemptNumber int
Queue string
RuleVersion string
Outcome string
}
type AuditLog interface {
Append(RoutingEvent) error
}
func RecordRouting(event RoutingEvent, audit AuditLog) error {
if event.EventID == "" || event.RequestID == "" ||
event.RuleVersion == "" || event.Queue == "" {
return ErrIncompleteAudit
}
return audit.Append(event)
}
This record proves what the service decided. It does not prove that a handset displayed an SMS. That boundary should remain explicit in dashboards and compliance reviews.
How do you verify and roll back the runbook?
Test the state machine before testing message wording. Cover duplicate resend requests, a queue publish timeout, a duplicate delivery job, an expired challenge, an invalid code, and a database failure while writing the audit event. The expected result is not that every operation succeeds. The expected result is one recorded decision or an explicit pending state that an operator can reconcile.
Instrument counts for queued attempts, policy rejections, unknown outcomes, verification failures, and duplicate-job suppressions. Add latency around the queue and delivery boundary. Redact or minimize samples: phone numbers, free-form support text, and correlation IDs can identify a person when combined.
Roll out behind a rule-version switch. First record decisions without changing the destination, then compare the recorded decision with the current routing result on a controlled sample. Once the evidence is complete, enable the new rule for a bounded cohort. A rollback should restore the previous rule for new forms; it should not rewrite historical events or resend an OTP that is already unknown.
During an alert, freeze policy changes, inspect one challenge timeline, and find the last durable state transition. Clicking resend while watching a dashboard is not an investigation. If the queue is unavailable, keep the form in an explicit pending state and provide the approved fallback. If delivery is unknown, do not claim success and do not create a second challenge merely to improve a metric.
The practical decision rule is compact: one active challenge, one unique attempt key, bounded retries, separate delivery and routing evidence, and a rule version on every queue decision. That makes a Node.js API easier to integrate while giving support operations an explanation they can verify later.
References
- RFC 4226, HOTP: An HMAC-Based One-Time Password Algorithm: https://www.rfc-editor.org/rfc/rfc4226
- RFC 6238, TOTP: Time-Based One-Time Password Algorithm: https://www.rfc-editor.org/rfc/rfc6238
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- OWASP Logging Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Logging_Cheat_Sheet.html
- Apple, Mail Privacy Protection guide: https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios
Top comments (0)