Short answer: for event notifications sent by email or SMS, keep template ownership and delivery reconciliation in your application, and treat a send timeout as an unknown result until a worker checks the delivery record. A Node.js cron worker can poll that record without guessing that a timeout means rejection. For a marketplace compliance notice, the ledger must tell you which exact notice was rendered, which channel accepted it, and whether another message is safe.
This is a state problem, not a request-time problem. A timeout means the caller stopped waiting. The remote service may have accepted the message just before the connection disappeared. Resending immediately can duplicate a legal notice; discarding the job can leave a seller or buyer uninformed.
Start with the notice ledger and template owner
Give each business event a stable notification ID. Store the recipient, channel, template version, rendered-content hash, creation time, attempt count, provider message ID when available, current local state, and next reconciliation time. The rendered-content hash matters: a later edit to a template must not make an old audit record appear to contain new wording.
For this scenario, the marketplace owns the template. The delivery service is responsible for transport and channel events; it should not silently become the source of truth for the compliance wording. Put template review, localization, retention, and approval in the same change process as the code that creates the notice.
The first useful states are queued, sending, acceptance_unknown, accepted, delivered, and terminal. They are application states, not a promise that every email or SMS system uses the same vocabulary. A timeout moves sending to acceptance_unknown. It does not prove rejection.
That small distinction prevents the worst retry.
Make the ambiguity visible.
Email authentication is part of the surrounding design. DKIM signs message content and selected headers, as specified in RFC 6376, but a valid signature does not prove that a recipient's mailbox accepted or displayed the message. Keep authentication checks, provider events, and recipient-facing outcome as separate evidence in the audit record.
How can a Node.js cron worker reconcile email and SMS delivery status after a timeout?
The worker should claim due records, read delivery status, append an observation, and schedule another read only when the local state remains non-terminal. A cron trigger is enough to start this loop; it should not be the ledger itself. Use a database lease or compare-and-set update so two overlapping runs cannot both decide that a still-uncertain notice needs a fallback.
Polling needs a ladder rather than one magic number. Check a fresh uncertain record relatively soon, stretch the interval as it ages, and stop at a documented retention or business deadline. The right cadence depends on notice urgency, queue depth, rate limits, and the promise made to the recipient. I'm not sure there is a universal interval that survives those constraints.
The read operation should be boring. It needs an explicit timeout, bounded transport retries, Retry-After handling for HTTP 429, and an observation timestamp. It should not resend a message merely because a status read was slow. The reducer that maps a response to a local state must be schema-aware and must never move terminal backward.
Here is a provider-neutral polling skeleton. The adapter is deliberately the only place that knows how a channel's status is read; template rendering and compliance decisions stay in the application.
from dataclasses import dataclass
from datetime import datetime, timedelta, timezone
from typing import Literal, Optional
State = Literal[
"queued",
"sending",
"acceptance_unknown",
"accepted",
"delivered",
"terminal",
]
@dataclass
class Notice:
notification_id: str
channel: Literal["email", "sms"]
template_version: str
content_hash: str
state: State
next_check_at: datetime
provider_message_id: Optional[str] = None
def next_poll(now: datetime, age_minutes: int) -> datetime:
delay_minutes = min(60, max(2, 2 ** min(age_minutes // 10, 5)))
return now + timedelta(minutes=delay_minutes)
def reconcile(notice: Notice, now: datetime, read_status) -> Notice:
if notice.state == "terminal" or notice.next_check_at > now:
return notice
observation = read_status(
channel=notice.channel,
provider_message_id=notice.provider_message_id,
)
new_state = observation["local_state"]
# Terminal records are monotonic; the database transaction must enforce this too.
if notice.state == "terminal":
new_state = "terminal"
notice.state = new_state
notice.next_check_at = next_poll(now, observation["age_minutes"])
return notice
In production, read_status should return a validated result and the caller should write both that result and its raw response to an append-only audit table. The example omits the database transaction and network adapter on purpose: those details vary, while the ownership boundary does not.
What should happen when polling finds no delivery event?
No event is not the same as a failed send. It may mean the message is still being processed, the event has not reached the query surface, or the local correlation key is wrong. Record the empty observation, check the correlation data, and keep the item in an explicit non-terminal state until the policy says otherwise.
A returned acceptance is also not final delivery. Email can be suppressed, bounced, or filtered after submission. SMS status has its own channel lifecycle; keep the channel-specific raw status alongside the normalized state instead of throwing away detail during mapping. Public channel documentation is useful evidence when defining which provider states your adapter can actually observe.
Failover deserves a separate policy. If an SMS notice is uncertain, sending an email immediately may create two notices when the SMS later arrives. For a compliance notice, a late message and a duplicate message have different legal and support costs. Decide per event type whether failover is allowed, how long uncertainty may last, and which template version the fallback must use. An OTP, a payout warning, and a weekly digest should not share one timeout rule.
Template ownership changes the operational checklist
With independent template ownership, a deployment must preserve the exact content used for each notification. Render before the send attempt, store the version and hash, and make the audit entry durable before the worker can mark the attempt as ready. A retry may reuse that rendered payload; it should not silently render today's template for yesterday's compliance event. For example, imagine a seller-payout notice rendered on Monday with template version payout-v3, then held in acceptance_unknown after the load balancer closes the connection. On Tuesday, a copy edit changes the wording to payout-v4. The reconciliation worker should continue looking up the Monday record and preserve payout-v3; re-rendering during a retry would make the audit trail describe a message the recipient may never have received. The same rule applies to a fallback channel: its selected template version must be recorded as a new event, rather than overwriting the primary attempt.
The worker also needs clear limits. Cap immediate transport retries, honor rate-limit signals, and hand prolonged uncertainty to the scheduled poller. Alert on records stuck in sending or acceptance_unknown, but do not turn an application timeout into a delivery-failure alert without supporting status evidence.
Keep the audit vocabulary useful to a human reviewer: who triggered the event, which template version was selected, when each channel was attempted, what identifier came back, what was observed later, and why a fallback was or was not sent. A dashboard that shows only "sent" hides the exact ambiguity this design is meant to preserve.
The integration choice is secondary to those records. A compact comparison helps keep the decision honest:
| Approach | Access pattern | Best fit | Main limitation |
|---|---|---|---|
| Queryable status API | Scheduled REST reads from a cron worker | A team can tolerate delayed reconciliation and wants one local ledger | Polling consumes worker and rate-limit capacity |
| Webhook delivery events | Provider pushes status to an HTTPS endpoint | Near-real-time updates are a hard requirement | The endpoint needs authentication, replay handling, and durable event processing |
| Queue plus provider SDK | Worker calls a channel-specific library | The team accepts SDK lifecycle and channel-specific abstractions | Template and status semantics can become coupled to one integration |
None of these approaches removes the need for an application-owned template version, idempotency key, and audit record.
A staged rollout for timeout handling
First, write the ledger and capture template version plus content hash for new notices. Next, move timed-out attempts to acceptance_unknown without changing channel behavior. Run reconciliation in observation mode and compare its observations with provider records and support reports. Only then enable terminal transitions and carefully scoped failover.
Test the awkward boundaries: the send completes just after the request deadline; two cron workers claim one record; a terminal event appears between polls; a status read receives 429; the template changes during a retry; and a fallback is queued just as the original channel becomes delivered. These are state-transition tests, not merely API availability tests.
The trade-off is deliberate. A ledger, append-only observations, and scheduled polling add storage and operational work, and polling cannot provide the immediacy of a webhook-driven design. They are not suitable when a product requires near-real-time delivery callbacks or a channel's documented status cannot be queried. In that case, use a service with the required webhook and channel semantics, while keeping template ownership and the same audit rules in your application.
For a marketplace compliance notice, the safest decision rule is simple: own the wording locally, preserve the uncertain outcome, and make every later decision from recorded evidence rather than from the absence of a timely HTTP response.
Top comments (0)