A class opening should create one durable notification intent, not an immediate SMS API call. The deciding constraint is integration effort under retries: delivery polling, resend, cancellation, country policy, and rate limits all become manageable when the application owns a small state machine and treats the carrier-facing adapter as an unreliable boundary. Short answer: persist the intent and its idempotency key before dispatch, apply consent and destination policy before every attempt, accept delivery receipts through the same transition rules used by polling, and resend only when policy permits a new attempt.
This design also gives a gaming operator a coherent path for a related job: generating a session report and sending it as an email attachment. The report artifact, email delivery, and text alert are different channels, but they belong to the same audit model: immutable intent, versioned policy decision, bounded attempt, and attributable outcome. The attachment must never enter an SMS retry loop.
How should Node.js SMS notification alerts poll delivery status?
The first invariant is semantic idempotency. A waitlist promotion for member m-1842 into class occurrence c-9081 has one business key even if a worker crashes, an HTTP client times out, or an operator replays a queue partition. A database uniqueness constraint on (event_type, class_occurrence_id, member_id) is stronger than a best-effort cache because it survives process replacement and exposes duplicates during reconciliation.
The second invariant is that authorization to send is evaluated against a versioned policy record. A normalized destination country, consent evidence, quiet-hours decision, suppression status, and message category should be recorded with the attempt. Country guardrails are configuration owned by legal and operations teams; application code should not pretend that an EU or US branch captures all applicable obligations. Compliance limits change, and disputed sends need the exact rule version that admitted them.
Third, a delivery state is monotonic unless a documented exception says otherwise. queued may become submitted, and submitted may become delivered, failed, or unknown; a late polling response must not move delivered backward to submitted. Cancellation closes future work but cannot erase an already accepted carrier submission.
That distinction matters.
No guesswork.
The failure boundary is the adapter call. A timeout does not prove rejection, so retrying with a fresh request identifier can produce two texts. Store an attempt identifier first, pass a stable idempotency token where the downstream interface supports one, and reconcile an ambiguous result before creating another attempt. Exactly-once delivery is not a property the network grants; the useful engineering target is exactly-once business intent with recorded, deduplicated side effects.
Decision record: own the workflow, isolate the transport
The decision is to keep orchestration in the application database and expose a narrow transport interface. Webhooks and polling are two observations of the same attempt, not two competing sources of truth. Polling is scheduled only while an attempt is unresolved, with bounded exponential backoff and jitter; a webhook can settle the attempt first, after which the poller becomes a no-op.
| Option | Integration effort | Failure behavior | Audit quality | Decision |
|---|---|---|---|---|
| Send inline from the booking request | Low initially | User latency and request retries leak into dispatch | Weak unless every branch writes separately | Reject |
| Durable outbox plus application state machine | Moderate | Crash recovery and replay have explicit boundaries | Strong: intent, decision, attempt, and outcome join cleanly | Adopt |
| External workflow engine | Higher operationally | Timers and retries are durable by construction | Strong if business keys remain in the domain store | Reserve for broader orchestration |
| Scheduled batch scan | Low to moderate | Open seats can wait for the next scan; overlapping runs need locks | Adequate with a run ledger | Valid for non-urgent reports |
The adopted path adds schema and worker code, but it reduces the number of places where correctness can hide. The booking transaction inserts the promotion and outbox row together. A dispatcher claims eligible rows, obtains a policy decision, writes an attempt, and calls the adapter. Receipt ingestion and polling both submit observations to one compare-and-set transition function. A separate reconciler compares unresolved attempts with transport observations and raises aged ambiguity for human review.
Consider the awkward race rather than the happy path. At 18:00:00 a seat opens, the transaction commits promotion c-9081/m-1842, and the worker records attempt 1. The transport accepts the text, but the response is lost; at 18:00:02 a delivery callback reaches another process, while the original worker records unknown a few milliseconds later. Both writes carry an observed-at time and pass through a conditional transition, so delivered wins and the later ambiguous observation is retained as evidence without regressing state. At 18:01 an operator presses cancel after seeing the seat filled. Cancellation closes future work, yet it does not overwrite the receipt or manufacture a claim that the accepted text was recalled. This is why a single status column is inadequate: the current projection may be compact, but the append-only observations explain the outcome.
Rate limits belong at two levels. A global or route-level token bucket protects the transport boundary, while a recipient-level cooldown prevents a burst of individually valid class openings from becoming harassment. Neither limit should silently discard work: defer with a reason and a next eligible time, or terminally suppress it under an explicit rule. Backpressure is data.
For the generated gaming report, the same outbox points to an immutable artifact reference rather than embedding a mutable file in a queue message. The email worker resolves that reference, verifies the artifact metadata expected by the intent, constructs the attachment, and records submission. Google's sender guidance makes authentication, identity, and recipient expectations part of operating an email channel; those controls remain separate from SMS country policy, even though their evidence can share an audit envelope.
The critical path in Go
The interface below is deliberately smaller than any provider SDK. Transport-specific status names are mapped at the adapter edge, which prevents proprietary vocabulary from leaking into resend policy.
package notification
import (
"context"
"strconv"
"time"
)
type State string
const (
Queued State = "queued"
Submitted State = "submitted"
Delivered State = "delivered"
Failed State = "failed"
Unknown State = "unknown"
)
type Intent struct {
ID, BusinessKey, Destination, Country, PolicyVersion string
State State
Attempt int
}
type Submission struct {
RemoteID string
State State
}
type Transport interface {
Send(context.Context, string, string, string) (Submission, error)
Status(context.Context, string) (State, error)
}
type Store interface {
LockEligible(context.Context, time.Time) (Intent, error)
PolicyAllows(context.Context, Intent, time.Time) (bool, string, error)
BeginAttempt(context.Context, string, string, int) error
RecordSubmission(context.Context, string, string, State) error
Defer(context.Context, string, string, time.Time) error
}
func DispatchOne(ctx context.Context, now time.Time, db Store, tx Transport, body string) error {
in, err := db.LockEligible(ctx, now)
if err != nil {
return err
}
allowed, reason, err := db.PolicyAllows(ctx, in, now)
if err != nil {
return err
}
if !allowed {
return db.Defer(ctx, in.ID, reason, now.Add(15*time.Minute))
}
attempt := in.Attempt + 1
key := in.BusinessKey + "/attempt/" + strconv.Itoa(attempt)
if err := db.BeginAttempt(ctx, in.ID, key, attempt); err != nil {
return err
}
result, err := tx.Send(ctx, key, in.Destination, body)
if err != nil {
// Preserve ambiguity; reconciliation decides whether another send is safe.
return db.RecordSubmission(ctx, in.ID, "", Unknown)
}
return db.RecordSubmission(ctx, in.ID, result.RemoteID, result.State)
}
BeginAttempt must commit before Send. If the process dies between those operations, reconciliation sees a prepared attempt with no remote identifier and applies a documented ambiguity policy instead of guessing.
Cancellation uses the same discipline. A cancel request marks the intent closed under a row lock. A dispatcher that already owns an attempt may still submit, so the audit trail records the race and downstream code does not claim that cancellation recalled a message. If the transport supports cancellation, the adapter may request it, but the domain still waits for an observed terminal result.
Polling should never be a fixed one-minute loop over every row. Persist next_check_at, claim a bounded page, add jitter, and stop after a configured horizon. The terminal action at that horizon is unknown, not failed; those states carry different resend risk.
Failure policy and the cost of a second attempt
A resend creates a new attempt under the original intent. It is allowed only after the previous attempt has a terminal failure that the policy classifies as retryable, or after an ambiguous outcome has been reconciled according to an approved rule. unknown alone is insufficient evidence. This conservative choice can miss a time-sensitive opening, but duplicating a promotion can make two members believe they own one seat, which is harder to reconcile fairly.
Use three clocks: the class-opening expiry, the recipient cooldown, and the transport retry window. The earliest closing clock wins. Once the seat is filled or the offer expires, pending dispatch and polling work is cancelled locally, while already submitted attempts continue to accept receipts for audit completeness.
Operational dashboards should distinguish intent counts from attempt counts. Useful reconciliation views include intents with no attempt, prepared attempts with no remote identifier, submitted attempts past their next check, conflicting terminal observations, and cancelled intents that later produced a delivery receipt. Alert on the age and business exposure of those sets, not raw HTTP error totals.
Testing follows the same boundaries. A deterministic fake transport should model acceptance followed by timeout, a receipt arriving before the send response is stored, duplicate receipts, out-of-order polling results, and cancellation racing with dispatch. Property tests can assert that a terminal state never regresses and that one business key cannot create two active intents. Run country-policy fixtures against pinned rule versions; review their ownership and effective dates during deployment.
The deployment order matters: add states and nullable audit columns first, deploy readers that tolerate both representations, then enable writers and workers behind a percentage gate. Reconciliation must run before volume ramps, because it is the proof that the ledger of intent agrees with the transport edge. Keep old workers able to drain or fence them with a lease version.
The limitation of an application-owned workflow is plain: the team must operate leases, timers, reconciliation, and migration discipline. It is not suitable when a small team already has many long-running approval and compensation flows but no capacity to maintain another scheduler; a general workflow engine is the better boundary there. The trade-off runs the other way for a narrow waitlist system, where adopting a second orchestration platform can create more integration surface than the two or three durable timers justify.
Choose consciously.
Why reject direct callbacks as the whole design?
A webhook-only implementation looks economical because it removes polling code. It fails as the sole design when callbacks are delayed, duplicated, reordered, or cannot be correlated after configuration drift. The valid use case is as the fast observation path inside the state machine: verify and persist the callback, deduplicate its event identifier, then attempt the same monotonic transition that polling uses.
Likewise, calling the SMS adapter inside the request that fills a class seat is valid for a disposable prototype where duplicate notification risk is accepted and no audit obligation exists. It is the wrong boundary once a promotion, consent decision, and disputed delivery must reconcile. The small apparent integration saving becomes coupled retries and missing evidence.
An external workflow engine becomes attractive when the system has many long-running timers, multi-channel escalation, operator approvals, and compensation steps. Its valid use case starts where the application-owned scheduler becomes a platform of its own. Even then, retain the business idempotency key and policy evidence in a domain-controlled store; orchestration history is useful, but it should not be the only ledger explaining why a person was contacted.
The decision rule is compact: adopt the smallest durable mechanism that can preserve one business intent, version the send authorization, serialize state transitions, and reconcile ambiguous transport outcomes. For a fitness waitlist, that is usually an outbox plus a narrow adapter and explicit poll schedule. For generated email reports, reuse the audit envelope, not the SMS lifecycle. Integration effort is justified by the failure states it makes inspectable.
Top comments (0)