SMS OTP API Error Handling Through Recipient Suppression and Bounded Evidence
TL;DR: In Node.js, SMS OTP resend API handling should make every retry an idempotent command against one challenge, place the resulting message in a transactional outbox, and classify delivery errors before changing recipient eligibility. Suppress an address or phone number only after a permanent, identity-specific failure; retry transient transport failures within the challenge deadline. Keep a compact audit record longer than raw provider payloads. This is the least complex design that prevents a double-click from creating two valid codes while still giving B2B SaaS support and reconciliation enough evidence to explain a failed login.
The bill for this design is usually made of evidence, not OTP arithmetic: request rows, outbox rows, delivery events, indexes, replicas, and backups all grow with every send attempt. Consider an illustrative workload of 10 million OTP commands per month, two delivery events per command, and 900 bytes per normalized event. The event bodies alone occupy 18 GB per month (10,000,000 x 2 x 900), before indexes, replication, or backup copies. A 90-day raw-event window therefore starts at 54 GB. Those are model inputs, not benchmark results; substitute measured row sizes and actual event counts before making a retention decision.
The change that moves the dominant term is compaction. Retain one small challenge record and one current recipient-status record for the audit period your organization has approved, but expire duplicate transport updates and raw callback bodies sooner. Do not weaken the security boundary to save storage: the challenge record still needs its identifier, tenant, purpose, timestamps, outcome, and request key. The intentional loss is packet-level forensic detail after the short raw-event window. If a dispute arrives later, the team can prove the decision and its timing, but it may no longer be able to reconstruct every field delivered by a transport.
How should an SMS OTP API classify delivery errors?
A resend endpoint should answer a narrow question: may this tenant deliver the current authentication challenge to this recipient again? It should not mean "create another independent credential." In a Node.js HTTP service, parse and authenticate the request at the edge, then pass a command containing tenant_id, challenge_id, recipient_id, purpose, and an idempotency key to the application layer. The persistence transaction owns the decision. The JavaScript runtime is not the coordination mechanism; the database constraint is.
The idempotency scope matters. A key unique only by request string can collide across tenants, while a key unique only by challenge prevents a legitimate later resend. A practical uniqueness tuple is tenant, challenge, operation, and client-generated key. Replaying that tuple returns the existing dispatch identifier and state. A new key may request another dispatch, but the server still applies the challenge deadline, resend interval, attempt ceiling, and recipient status in one transaction.
Short answer: one committed transaction should lock the challenge, reject terminal or expired states, check suppression and rate policy, insert a dispatch, and insert an outbox item. A worker may publish the outbox item more than once, so the transport adapter also claims the dispatch identifier atomically. This is an exactly-once business decision built over components that can redeliver. No distributed promise of exactly-once delivery is required.
Delivery is separate.
The following Go model makes the state boundary explicit; a Node.js implementation should preserve the same transaction and uniqueness rules rather than copy language syntax.
package otp import ( "context" "errors" "time" ) var ( ErrExpired = errors.New("challenge expired") ErrSuppressed = errors.New("recipient suppressed") ErrTooSoon = errors.New("resend not yet allowed") ) type Resend struct { TenantID string ChallengeID string RecipientID string IdempotencyKey string RequestedAt time.Time } type Dispatch struct { ID string State string CreatedAt time.Time } type Challenge struct { ExpiresAt time.Time NextResendAt time.Time Terminal bool RecipientBlocked bool } type Store interface { WithChallengeLock(context.Context, Resend, func(Challenge) (Dispatch, error)) (Dispatch, error) FindDispatch(context.Context, string, string, string) (Dispatch, bool, error) InsertDispatchAndOutbox(context.Context, Resend) (Dispatch, error) } func RequestResend(ctx context.Context, db Store, cmd Resend) (Dispatch, error) { if prior, ok, err := db.FindDispatch(ctx, cmd.TenantID, cmd.ChallengeID, cmd.IdempotencyKey); err != nil || ok { return prior, err } return db.WithChallengeLock(ctx, cmd, func(ch Challenge) (Dispatch, error) { if ch.Terminal || !cmd.RequestedAt.Before(ch.ExpiresAt) { return Dispatch{}, ErrExpired } if ch.RecipientBlocked { return Dispatch{}, ErrSuppressed } if cmd.RequestedAt.Before(ch.NextResendAt) { return Dispatch{}, ErrTooSoon } return db.InsertDispatchAndOutbox(ctx, cmd) }) } The lookup before the lock is an optimization, not the guarantee. The database must enforce the same uniqueness tuple, and a uniqueness conflict should be read back as the original result. Store a verifier or keyed digest of the code rather than the code itself, and never put the code, full phone number, or full email address into an audit event. NIST requires verifiers to rate-limit failed authentication attempts and treats PSTN out-of-band authentication as restricted; the service should therefore make policy limits configurable and observable rather than burying them inside a transport adapter.
Which errors change recipient eligibility?
Transport acceptance is not delivery. An accepted dispatch remains pending until a terminal delivery signal or the local reconciliation deadline; returning HTTP success from the resend API should mean that the command was durably accepted, not that a person received a message. This distinction keeps client retries honest and prevents the login UI from claiming success based on a queue acknowledgement.
Use a small internal taxonomy and preserve the original external code separately. invalid_recipient and an authoritative hard bounce are permanent for the normalized destination until the user corrects or re-verifies it. rate_limited, timeout, unavailable transport, and a soft bounce are transient. policy_blocked is terminal for the current attempt but may require a consent or tenant-policy transition rather than permanent suppression. An unknown code is not evidence that an identity is invalid; quarantine it for reconciliation and default to a bounded retry policy.
| Normalized outcome | Current dispatch | Recipient status | Automatic action |
|---|---|---|---|
| accepted or pending | nonterminal | unchanged | wait for a receipt until the deadline |
| transient failure | retryable | unchanged | retry with backoff inside the challenge lifetime |
| invalid recipient or hard bounce | terminal | suppressed for that channel | require correction and verification |
| policy or consent block | terminal | policy-blocked | wait for an explicit policy transition |
| unknown external code | needs review | unchanged | stop blind retries and reconcile |
Suppression belongs to a channel-specific recipient identity, not to the whole user. A hard email bounce must not silently disable a verified phone number, and an invalid mobile number must not poison a corrected number later supplied by the same user. Normalize cautiously, retain the version of the normalization rule, and create a new recipient identity when the canonical destination changes. Otherwise, a stale suppression row can follow a user after correction.
There is a related email trap. Open tracking is not a delivery or identity signal: Apple Mail Privacy Protection can download remote content in the background and prevent senders from learning whether a recipient opened a message. Do not clear or set suppression from an open pixel. SMTP enhanced status codes and authoritative delivery events are the relevant evidence for bounce classification.
How do retries avoid duplicate codes and duplicate sends?
There are two clocks: the security clock for the challenge and the operational clock for delivery. Backoff must fit inside the former. Once the challenge expires or succeeds, pending work becomes terminal and workers must refuse new transport calls, even if a delayed queue item appears later. RFC 6238 also requires a verifier to accept a successfully validated one-time password only once during its validity window.
Keep the API response stable across an idempotent replay. A useful response includes the opaque dispatch identifier, state, challenge expiry, and a server-derived next-action time; it excludes the code and avoids revealing whether an untrusted login identifier belongs to an account. Map local outcomes consistently: malformed input is a client error, a policy or suppression decision is a stable rejection, premature resend is rate-limited with a bounded retry time, and an unavailable dependency after no durable commit is retryable. If the transaction committed but the connection broke, the client repeats the same key and receives the committed dispatch. That final case is why idempotency cannot be an in-memory cache.
Delivery workers need a second deduplication boundary. Claim a dispatch with a compare-and-set from queued to sending; attach the dispatch ID as the adapter's correlation key where the transport supports one; and record every state transition with actor, reason, source timestamp, observation timestamp, and previous state. A late delivered event must not resurrect an expired challenge, while a late permanent failure may still update the recipient's channel eligibility if its authenticity and ordering can be established.
The state machine should reject regressions.
package otp import "fmt" type DeliveryState string const ( Queued DeliveryState = "queued" Sending DeliveryState = "sending" Accepted DeliveryState = "accepted" Delivered DeliveryState = "delivered" Failed DeliveryState = "failed" ) var allowed = map[DeliveryState]map[DeliveryState]bool{ Queued: {Sending: true, Failed: true}, Sending: {Accepted: true, Failed: true}, Accepted: {Delivered: true, Failed: true}, } func ValidateTransition(from, to DeliveryState) error { if !allowed[from][to] { return fmt.Errorf("delivery transition %q to %q is not allowed", from, to) } return nil } This table intentionally omits a transition out of a terminal state. Reconciliation can append contradictory evidence without rewriting history; a separate resolution record says which observation won and why. That audit trail is more useful than mutating a row until it resembles the latest callback.
There is a real trade-off. A transactional outbox adds a table, a relay, cleanup work, and another lag metric, so it does not fit every system. For a low-volume internal tool whose operator can tolerate an occasional manual retry and has no approved requirement to preserve delivery evidence, a synchronous send plus a short-lived idempotency record may be the better boundary. Conversely, the compacted evidence model is unsuitable when an investigation must reproduce an external callback byte for byte after the raw-event window; that requirement calls for longer encrypted archival storage, stricter access controls, and a correspondingly larger retention bill. Neither choice changes the authentication rule: one challenge must not become several simultaneously valid credentials.
That boundary is deliberate.
Operating the boundary under failure
Test the invariants, not a vendor's happy path. Concurrent requests with one idempotency key must return one dispatch. Concurrent requests with different keys must still respect the resend interval and attempt ceiling. A process crash after transaction commit but before the HTTP response must be recoverable by replay. A worker crash after a transport call must not create an unbounded send loop. Out-of-order, duplicated, delayed, and unknown delivery events must leave the challenge and recipient in permitted states.
Observability should separate user intent from transport work. Count resend commands, idempotent replays, policy rejections, dispatch attempts, terminal outcomes by normalized class, suppression changes, and reconciliation lag. Avoid recipient values in labels because they leak sensitive data and create unbounded cardinality. Alert on ratios and age: a growing oldest-pending age can expose a stuck path even when total traffic is low. Logs should carry tenant-safe correlation identifiers and the audit event ID.
Deployment deserves the same caution as code. Add nullable state first, deploy readers that tolerate old and new records, backfill in bounded batches, then enable writers and constraints. Changing an error mapping is a data migration because it can alter suppression. Version the mapping, run historical samples through the new classifier without applying effects, review the differences, and only then activate it. Rollback must restore the previous classifier without deleting evidence produced by the newer one.
For retention, calculate rather than guess:
package retention func RawBytes(commands, eventsPerCommand, bytesPerEvent, months int64) int64 { return commands * eventsPerCommand * bytesPerEvent * months } func StorageBytes(rawBytes, indexBasisPoints, replicaCopies int64) int64 { indexed := rawBytes + rawBytes*indexBasisPoints/10_000 return indexed * replicaCopies } Feed this model measured row and index sizes from production-like data. Legal, security, and compliance owners must approve actual windows; there is no universal retention period for every B2B tenant or jurisdiction. The deliberate design is to keep normalized decisions and their lineage for the approved audit window while deleting raw callback bodies, redundant queue attempts, and sensitive transport metadata sooner. The price of that boundary is reduced late-stage forensics. The benefit is a smaller sensitive-data footprint and a storage term that no longer grows with every duplicate callback for the full audit period.
The reliability rule to keep
Treat resend as a durable, idempotent state transition, not a second code generator. Treat delivery feedback as evidence that passes through a versioned classifier, not as permission for an arbitrary callback to disable a user. Keep channel suppression narrow, reversible through verified correction, and independent of challenge validity.
Most importantly, preserve the distinction between command acceptance, message delivery, and authentication success. It prevents duplicate credentials, makes reconciliation possible, and gives a B2B SaaS operator a defensible answer when a tenant asks why a recipient could not log in. Retain that answer. Discard the bulky raw material on a separately approved schedule, accepting that very old investigations will have less transport-level detail.
Further reading
- NIST Digital Identity Guidelines, Authentication and Authenticator Management: https://pages.nist.gov/800-63-4/sp800-63b.html
- RFC 6238, TOTP: Time-Based One-Time Password Algorithm: https://www.rfc-editor.org/rfc/rfc6238
- RFC 4226, HOTP: An HMAC-Based One-Time Password Algorithm: https://www.rfc-editor.org/rfc/rfc4226
- RFC 3463, Enhanced Mail System Status Codes: https://www.rfc-editor.org/rfc/rfc3463
- OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
- Apple Mail Privacy Protection guide: https://support.apple.com/guide/iphone/use-mail-privacy-protection-iphf084865c7/ios