Short answer: for media event notifications sent by email or SMS, treat a request timeout as an evidence gap, then reconcile it with a small Node.js worker and an append-only record. The design decision is about what you can prove later, not which client library sends the first request.
The first useful artifact is a decision matrix. It forces the team to name the trade-off before anyone writes a retry loop.
| Approach | SDK / REST access | Onboarding cost | Good fit | Main limitation |
|---|---|---|---|---|
| Provider SDK in the cron worker | SDK; REST hidden behind it | Low for one language | A small Node.js service with one channel | Couples the worker to one runtime and provider model |
| Direct REST adapter | REST over HTTPS | Medium; you own auth and response mapping | Teams supporting email and SMS behind one interface | You must track API version and status vocabulary |
| Self-hosted mail transfer agent plus SMS adapter | SMTP plus a separate REST client | High; queues and feedback need operations | Organizations that already run messaging infrastructure | More moving parts and weaker evidence if feedback is incomplete |
| Webhook with replayable inbox | REST submit plus webhook receive | Medium to high; requires ingress and replay | Providers with durable status events | A webhook alone does not solve a lost or late event |
For this scenario, I would use a direct adapter with a cron worker, while keeping the internal contract provider-neutral. It gives the compliance team a stable record and lets the delivery integration change without rewriting the notice workflow.
What does a compliance record need to prove after an email or SMS timeout?
Start with claims, not statuses. A reviewer needs to distinguish “we rendered this notice,” “a channel accepted an attempt,” and “the channel later reported a terminal result.” A client timeout proves none of those by itself.
Store an immutable notice ID, a channel-specific attempt ID, the normalized destination, template revision, content hash, request time, and every observation. Keep unknown separate from failed; the remote side may have accepted a request just before your socket gave up.
type DeliveryState = "accepted" | "delivered" | "failed" | "unknown";
type DeliveryEvent = {
noticeId: string;
attemptId: string;
channel: "email" | "sms";
state: DeliveryState;
observedAt: string;
source: "submit" | "poll" | "webhook";
providerMessageId?: string;
payloadHash: string;
};
Do not overwrite one status column. Append an event and derive the current view. That ordering matters when a delayed polling result arrives after a retry worker has already written another observation.
For email, retain the message identifier and the DKIM-related headers you actually send. RFC 6376 explains how a verifier checks a domain signature; a passing signature still does not prove inbox placement. For SMS, keep the provider message identifier and exact template revision. Those fields make a later export inspectable instead of anecdotal.
Small record. Serious difference.
How can a Node.js cron worker reconcile delivery status without webhooks?
The worker needs four boring properties: a lease, an idempotency key, bounded polling, and an explicit stopping rule. Claim one due unknown attempt, query its status, append the observation, and release the lease even when the process is interrupted.
async function reconcile(attempt: Attempt): Promise<void> {
if (attempt.state !== "unknown") return;
const lease = await ledger.claim(attempt.id, 30_000);
if (!lease) return;
try {
const result = await statusClient.lookup({
channel: attempt.channel,
providerMessageId: attempt.providerMessageId,
});
await ledger.append({
...result,
attemptId: attempt.id,
source: "poll",
observedAt: new Date().toISOString(),
});
} finally {
await ledger.release(attempt.id);
}
}
Use an idempotency key tied to noticeId + channel, not to a transient job ID. I once assumed an 8-second timeout meant “nothing happened.” The remote side had accepted the request at second 7, and a retry at second 9 created a duplicate notice. The queue showed one job, the provider console showed two message identifiers, and the compliance export could not explain why until we compared the request hash and timestamps. The fix was a ledger rule that refused a second submission while the first attempt remained unknown; the worker could then keep polling the original identifier instead of guessing.
That awkward state is useful.
Poll on a policy schedule, such as 30 seconds, then 2, 5, and 15 minutes. Those intervals are examples, not delivery guarantees. Stop at the documented status lifetime or your legal evidence deadline. If no terminal result exists then, retain unknown and mark the evidence incomplete. Do not manufacture failed just to make a dashboard green.
Which failure boundaries should the test suite and runbook expose?
Inject a timeout after the remote side accepts the request. Send the same status twice. Deliver observations out of order. Kill the cron process after it acquires its lease. Advance the clock past the polling deadline. The assertions are concrete: one notice has one attempt identity, events remain append-only, and an unresolved state stays visible.
Measure the age of the oldest unknown attempt, reconciliation lag, duplicate-key rejects, and terminal-to-unresolved ratios by channel. Alert on age and backlog. Your mileage may vary on thresholds; traffic shape and the retention window decide them.
The evidence boundary has limits. A provider-reported delivered state is testimony from that channel, not proof that a person read the notice. A DKIM pass authenticates a domain signature, not legal receipt. Put those distinctions in the runbook so an auditor does not infer more than the record supports.
When is this polling design the wrong choice?
The catch is status availability. Polling is not suitable when a channel exposes no queryable message state, retains status for less time than your policy, or makes lookups operationally impractical. In that case, choose a channel with durable event history or negotiate a webhook contract that supports replay and deduplication.
Self-hosting can also be the wrong boundary. Keep a mail transfer agent only when the team can operate its queues, signing keys, bounce processing, and feedback loops. Otherwise, the extra control may produce less evidence, not more. I am not sure any single transport can establish human receipt; the policy should say what “sufficient evidence” means before implementation starts.
The decision rule is simple: choose the path that leaves a reconstructable chain from rendered notice to channel observation. Everything else is a delivery convenience.
Top comments (0)