Short answer: Choose webhooks over polling for delivery status in a Node.js customer-support system that sends short-lived password-reset messages, provided the team can authenticate callbacks and commit them durably; choose bounded polling when public callback ingress is prohibited. The deciding constraint is not nominal API simplicity. It is how much status evidence the system creates, how long that evidence remains useful, and how reliably operators can reconstruct one message's history without retaining the reset secret.
Start with the bill. For N messages observed for W seconds at an interval of I seconds, polling permits up to N * ceil(W/I) status reads. A workload of 10,000 messages, a 120-second observation window, and a 5-second interval therefore has a planning ceiling of 240,000 reads. This is arithmetic, not a benchmark or price claim; terminal results can end a loop early. Webhooks replace that repeated-read term with callback attempts, durable event writes, queue work, indexed audit data, and a much smaller reconciliation scan. The change that moves the dominant controllable term is simple: receive state transitions, then poll only records whose expected callback has not arrived by the reconciliation threshold.
Storage can quietly become the larger term because the same callback may be copied into an ingress log, queue envelope, database row, and search index. Retain the provider message identifier, internal request identifier, normalized transition, source, event time, receipt time, and idempotency key for the approved investigation window. Deliberately discard message bodies, reset tokens, and raw callback payloads once authentication, normalization, and the policy-defined dispute window no longer require them. That deletion reduces linkable data and indexing cost, but it also removes the ability to replay a future parser dispute byte for byte.
That loss is real.
How should a Node.js SMS alerts API compare webhook and polling delivery status?
Use the delivery-status contract, rather than the number of endpoints, as the comparison unit. A small API can still leave critical behavior unspecified, while a larger API can expose a precise state machine. The comparison should record whether each observation has a stable message identifier, whether callbacks can be authenticated over the original request bytes, which statuses are terminal, how duplicate and out-of-order observations are represented, how long queryable status remains available, and what limits govern reconciliation reads. If any answer is undocumented, keep it as an explicit unknown in the adapter and procurement record.
| Decision test | Webhook-first | Polling-first |
|---|---|---|
| Routine traffic | Proportional to reported transitions | Proportional to open records and interval |
| Freshness | Depends on callback arrival and ingestion | Bounded by the chosen interval |
| Network boundary | Authenticated public ingress required | Outbound query access required |
| Duplicate control | Callback identity plus ledger uniqueness | Query identity plus scheduler uniqueness |
| Recovery | Reconcile aged nonterminal records | Continue bounded scheduled reads |
| Evidence created | Callback attempts and transitions | Query results and scheduling decisions |
No row proves that a text was seen by the account owner. Transport status is operational evidence, not authentication evidence, and it must not activate an account, extend a reset expiry, or consume a reset token. OWASP's forgot-password guidance requires consistent outward messages and timing, a side channel, securely stored random tokens, single use, expiry, and protection against excessive requests. Those controls belong to the reset workflow regardless of the status mechanism.
The catch is deployment policy. Webhook-first is not suitable when the environment cannot expose authenticated callback ingress, when the callback has no deterministic identity for deduplication, or when the team cannot operate a durable receiver and rotate its verification secret. Stick with polling-first then, but set an interval, a hard observation deadline, rate limits, and backoff. Polling without a deadline is a retention policy disguised as a loop.
Make duplicate observations harmless and visible
Exactly-once network delivery is not a credible assumption. Aim for an exactly-once effect at the database boundary: append each unique observation and update the current projection in one transaction, while preserving duplicates as a metric rather than applying them twice. Both webhook ingestion and reconciliation must call the same transition function. Otherwise two producers can create two interpretations of the same message, and an audit trail will faithfully preserve the disagreement without explaining which interpretation won.
The following Go core is deliberately independent of the Node.js edge process and any commercial transport. It illustrates the invariant the Node.js service must enforce at its own transactional boundary; all executable code remains Go so the state rule is unambiguous.
package status
import (
"context"
"errors"
"time"
)
type Observation struct {
IdempotencyKey string
MessageID string
RawStatus string
Normalized string
Source string
OccurredAt time.Time
ReceivedAt time.Time
}
type Store interface {
WithinTransaction(context.Context, func(context.Context) error) error
Append(context.Context, Observation) (bool, error)
Project(context.Context, string, string, time.Time) error
}
func Record(ctx context.Context, store Store, observation Observation) error {
if observation.IdempotencyKey == "" || observation.MessageID == "" {
return errors.New("missing observation identity")
}
return store.WithinTransaction(ctx, func(tx context.Context) error {
inserted, err := store.Append(tx, observation)
if err != nil || !inserted {
return err
}
return store.Project(
tx,
observation.MessageID,
observation.Normalized,
observation.OccurredAt,
)
})
}
The projection method still needs a documented transition table. Never infer precedence merely from arrival order: a delayed intermediate observation must not move a terminal record backward, an unfamiliar status must remain unfamiliar, and two conflicting terminal observations must both remain in the ledger for adjudication under the selected transport's documented semantics. Concise code does not remove that policy decision.
Separate reset security from transport evidence
A password-reset request creates at least three identities: the reset request, the outbound message, and each status observation. Link them with opaque internal identifiers, not with the token itself. Store a protected representation of the token separately, constrain its lifetime, invalidate it after use, and ensure status callbacks have no authority over token state. The callback handler verifies the transport-specific signature against the untouched body, checks freshness according to the documented scheme, commits the observation, and acknowledges only after the durable append. Projection, notifications, and support-facing views can run behind that boundary.
Keep the outward reset response consistent for existing and nonexistent accounts. Rate-limit by appropriate account and destination signals, without revealing which signal triggered a control. A fresh reset request should supersede or invalidate prior credentials according to one documented rule; it should not restart delivery polling for every historical message. Short expiry narrows the useful life of a stolen token, but it also means late transport evidence may be operationally interesting after it has become irrelevant to authorization.
There is a compliance limit here: an audit purpose does not justify indefinite retention. Phone numbers, message bodies, request metadata, and even stable hashes can remain linkable. The applicable policy and legal review must determine purpose, access, and deletion periods; no universal duration can be derived from transport behavior alone. I'm not sure a retention design is defensible until its owner can name the investigation question each retained field answers.
Keep less.
Test the evidence lifecycle, not just the happy path
Contract tests should feed duplicate observations, reversed order, unknown statuses, invalid authentication, queue redelivery, concurrent webhook and poll results, transaction rollback, and a transport result arriving after token expiry. State-machine tests need no network. Adapter fixtures should be dated and redacted, while authentication fixtures must preserve the exact byte representation required by the selected callback contract.
Deployment should begin with shadow comparison: let the established status path remain authoritative while the new path writes observations that do not drive the support projection. Compare missing transitions, duplicate ratios, normalization disagreements, and observation latency over a representative window. Promotion occurs only after those differences are understood. Rollback then changes which producer drives the projection; it does not erase the append-only evidence or change the canonical event shape.
Operate from a few age-based signals: oldest nonterminal message, reconciliation backlog, callback-to-commit latency, authentication rejection count, duplicate ratio, and terminal outcomes by destination region. Thresholds must come from the support objective and measured baseline, not from a universal percentage. Logs should carry opaque request and message identifiers, never reset tokens or full message bodies, and access to the ledger should itself be auditable.
This method costs engineering time. At very low volume, a secured receiver, queue, secret rotation process, and on-call surface can cost more than a bounded poller. Polling is the correct choice under that boundary. Once repeated reads or status latency become material, webhook-first with narrow reconciliation gives a cleaner evidence lifecycle: routine transitions arrive once, uncertainty is searched deliberately, and the reset credential remains outside the delivery ledger.
Top comments (0)