Treat an SMS timeout as an unknown outcome, not a failed send: accept each password-reset event once, persist its expiry and idempotency key before dispatch, and retry only through a worker that can reconcile the original attempt. For a short-lived e-commerce reset token, compliance evidence is the deciding constraint. The system must be able to show what it accepted, what it attempted, when it stopped, and why, without storing the token or message body in an audit log.
This changes the shape of the endpoint. A Node.js Express handler may receive the event, but it shouldn't hold the HTTP request open while an SMS provider decides the final delivery state. Return an accepted response after durable admission, then expose status from local state. The Go example below shows the same transport-independent contract because the hard part isn't an Express API call; it's controlling ownership of retries.
One event, one logical notification.
How should event notifications handle SMS timeout, retry, and duplicate sends?
Use two identifiers with different jobs. event_id identifies the business action, such as one password-reset request. idempotency_key identifies the logical notification command. A unique constraint on the key makes two concurrent HTTP requests converge on one stored record; checking memory before an insert is not enough because two processes can pass that check together.
A timeout leaves three possible realities: the provider never accepted the request, it accepted the request but the response was lost, or it accepted and sent the message before the caller stopped waiting. Retrying immediately as though the first case were certain is how customers receive two reset messages. Declaring success is no better. The durable record should therefore enter dispatch_unknown, keep the provider's attempt identifier when one exists, and move through reconciliation before another send can be authorized.
Status polling serves a different purpose from retry. Polling reads the provider's view and updates the local record; it must not create a second message. That separation is small on a diagram and easy to blur in code — especially when a generic checkAndRetry() function owns both operations.
Don't combine them.
The expiry is also a dispatch boundary, not presentation metadata. Before every attempt, compare the current time with expires_at. Once the reset token is too close to expiry for a useful delivery, mark the notification expired and stop. The exact safety margin depends on observed queue and carrier delay, so I'm not sure a universal number would be defensible; resolve it from your own latency distribution and product policy, then record the chosen margin as configuration that can be audited.
The delivery contract and failure states
The useful contract is narrower than “send this string.” It says: admit a password-reset notification exactly once for a stable event identifier, never dispatch it after expiry, retain evidence of each state transition, and let callers inspect the logical result without triggering work. “Exactly once” here describes admission of the logical command. It does not pretend that an external SMS network participates in the same database transaction.
| Local state | Meaning | Permitted next action |
|---|---|---|
accepted |
The event and expiry are durably stored | One worker may claim it |
dispatching |
A lease-holder owns the current attempt | Wait or reclaim an expired lease |
dispatch_unknown |
The request outcome is ambiguous | Reconcile status; do not blindly resend |
sent |
The provider accepted the logical message | Poll status or finish |
delivered |
Delivery evidence was observed | Finish |
failed_final |
A classified permanent failure occurred | Finish and surface a safe user action |
expired |
The reset window closed | Finish; require a new reset request |
Every transition needs a timestamp, old and new state, event ID, attempt number, and a reason code. Keep credentials, the reset URL, the token, and the full phone number out of those records. A redacted destination fingerprint can support correlation, but access to it still belongs under the same retention and authorization controls as the rest of the evidence.
There is a buy-versus-build decision hiding here. A managed notification service can own provider reconciliation and channel routing, reducing on-call surface, but its status vocabulary and evidence export may not match the controls an auditor expects. A direct provider integration exposes more detail and leaves fewer translation layers, while your team owns leases, retry classification, retention, and every 02:00 alert. A self-hosted dispatcher gives the strongest control over data placement and change timing; the catch is that it is not suitable when the team cannot staff the queue, database, and delivery integration as an on-call product.
| Approach | Compliance evidence | On-call load | Lock-in boundary |
|---|---|---|---|
| Managed notification layer | Verify export granularity and retention | Lower application burden, more dependency monitoring | Workflow and status model |
| Direct SMS integration | Build a record around raw attempt identifiers | Retry and reconciliation stay with the team | Provider request and status schema |
| Self-hosted dispatcher | Full control, full evidence design responsibility | Highest operational ownership | Internal schema and infrastructure |
No row wins by default. Stick with a managed layer when its evidence can satisfy the control and the reduced operational load matters more than adapting to its state model; choose direct integration when provider-level evidence is mandatory and the team can own the machinery; self-host only when control is worth the capacity and on-call budget.
A safe Go implementation for idempotency and status polling
The admission path should validate a compact schema, perform one transactional insert, and enqueue by record ID. If an AI agent or another dynamic client can originate events, publish the tool schema and reject unknown or missing fields at the boundary; explicit tool definitions reduce ambiguity about what the caller may send. The reset token itself should already have been created and stored by the identity system. This service needs a reference, an expiry, and a destination handle, not authority to mint credentials.
Here is the core shape. Store.Admit must use a database unique constraint on IdempotencyKey; Sender and Store are interfaces so the HTTP layer cannot quietly acquire retry behavior. The same handlers can sit behind an Express-facing gateway without changing the state rules.
package notifications
import (
"context"
"encoding/json"
"errors"
"net/http"
"time"
)
var ErrConflict = errors.New("idempotency key belongs to another event")
type ResetEvent struct {
EventID string `json:"event_id"`
IdempotencyKey string `json:"idempotency_key"`
DestinationRef string `json:"destination_ref"`
ResetRef string `json:"reset_ref"`
ExpiresAt time.Time `json:"expires_at"`
}
type Record struct {
ID string `json:"id"`
EventID string `json:"event_id"`
State string `json:"state"`
ExpiresAt time.Time `json:"expires_at"`
UpdatedAt time.Time `json:"updated_at"`
}
type Store interface {
Admit(context.Context, ResetEvent) (Record, bool, error)
Get(context.Context, string) (Record, error)
}
type Queue interface {
Publish(context.Context, string) error
}
type API struct {
Store Store
Queue Queue
Now func() time.Time
}
func (a API) AdmitReset(w http.ResponseWriter, r *http.Request) {
var event ResetEvent
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 16<<10))
dec.DisallowUnknownFields()
if err := dec.Decode(&event); err != nil {
http.Error(w, "invalid event", http.StatusBadRequest)
return
}
if event.EventID == "" || event.IdempotencyKey == "" ||
event.DestinationRef == "" || event.ResetRef == "" ||
!event.ExpiresAt.After(a.Now()) {
http.Error(w, "invalid event", http.StatusBadRequest)
return
}
record, created, err := a.Store.Admit(r.Context(), event)
if errors.Is(err, ErrConflict) {
http.Error(w, "idempotency conflict", http.StatusConflict)
return
}
if err != nil {
http.Error(w, "admission unavailable", http.StatusServiceUnavailable)
return
}
if created {
if err := a.Queue.Publish(r.Context(), record.ID); err != nil {
// A durable outbox must publish this accepted record later.
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusAccepted)
_ = json.NewEncoder(w).Encode(record)
}
func (a API) Status(w http.ResponseWriter, r *http.Request) {
record, err := a.Store.Get(r.Context(), r.URL.Query().Get("id"))
if err != nil {
http.Error(w, "notification not found", http.StatusNotFound)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(record)
}
The empty queue-error branch is deliberate architecture, but production code should not silently ignore it: the database transaction needs an outbox row, and a separate publisher needs to drain that row. The accepted record remains the source of truth if process termination lands between commit and publish. This closes a common gap in examples where the API returns 202, queue publication fails, and the event disappears even though the caller correctly stops retrying.
The worker needs a compare-and-set claim with a lease, then a fresh expiry check. Retry only failures classified as transient and only while the configured attempt and time budgets remain. On a timeout, preserve dispatch_unknown and reconcile through a read-only status operation keyed by the original attempt identifier. If the integration cannot reconcile an ambiguous attempt or honor an idempotency key, automatic resend is not suitable for password-reset SMS; stop, record the ambiguity, and let the user initiate a new reset flow after policy permits it.
Verification, capacity, and SLO evidence
Test the race, not merely the happy path. Send 100 concurrent admissions with the same idempotency key and assert that the store contains one logical record and the outbox contains one publishable item. Repeat with the same key and a different event ID; every request must resolve to a 409 conflict rather than silently attaching the new reset to the old notification. Then terminate the process after the database commit but before queue publication and confirm that the outbox publisher recovers the record.
The timeout test needs a controllable fake sender. Have it accept an attempt and withhold its response, then make reconciliation report that same attempt as accepted. The expected dispatch count is one. In a second case, let reconciliation remain unknown until the reset expires; the expected terminal state is expired, still with one dispatch. These are more valuable than a test that only checks exponential delay arithmetic because they exercise the point where duplicate sends are actually created. For capacity planning, start with peak password-reset admissions per second, multiply by the worst permitted number of status reads per logical notification, and add replay headroom for a delayed queue partition. That result sizes workers and provider-read allowance; average traffic does not. Keep dispatch and polling in separate worker pools so an extended status backlog cannot consume every slot needed for new resets. Your mileage may vary because carrier latency, reset expiry, and provider polling limits are deployment inputs, not universal constants. Use an SLO that follows the user-visible and compliance-relevant outcome: the proportion of accepted, unexpired reset events that reach a terminal local state within the chosen window. Pair it with guardrails for duplicate logical dispatches, oldest accepted-record age, unknown-state age, outbox lag, and expired-before-dispatch count. Alerting on raw provider errors alone misses a stuck outbox; alerting on queue depth alone misses a fast loop generating duplicate sends. Audit queries should reconstruct a timeline without joining against message content. Sample evidence before launch: one accepted-and-delivered event, one duplicate admission, one idempotency conflict, one timeout reconciled without resend, and one event stopped by expiry. If those five narratives cannot be produced from retained records under the auditor's access path, the implementation isn't ready regardless of its unit-test coverage.
Measure the ambiguity.
Rollback without reopening duplicate delivery
Rollback the dispatcher and the schema independently. A deployment that changes state names or retry classification should first ship readers that understand both versions, then writers, then cleanup after the maximum notification lifetime and evidence-retention compatibility window. During rollback, freeze automatic retry for records whose state the old worker cannot interpret; don't coerce dispatch_unknown back to accepted, because that converts uncertainty into permission to send again.
Keep one operational switch that stops new dispatch while admission and status reads continue. This preserves evidence and prevents callers from creating their own retry storm during investigation. The runbook should name who can activate it, how queued records age toward expiry, what metric authorizes reopening, and how to prove that no two worker versions own the same lease semantics.
Short-lived credentials make conservative failure behavior acceptable: a customer can request a fresh reset, while a duplicated security message cannot be recalled. The trade-off is real. This design is not suitable when every event must be delivered despite ambiguity; for that requirement, the messaging contract must provide deduplication or reconciliation strong enough to justify another attempt.
References
- RFC 8058, “Signaling One-Click Functionality for List Email Headers”: https://datatracker.ietf.org/doc/html/rfc8058
- Anthropic, “Tool use with Claude”: https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview
Top comments (0)