Short answer: make the notification center an append-only event ledger first, then let independent email and SMS workers attach report files and record every delivery attempt; polling should read that history, never infer it from provider state.
When a support agent requests a generated account report, the hard requirement is not “send an email.” It is proving what happened to that report, to whom, and when. A notification can be queued, rendered, handed to a provider, accepted, delayed, or rejected. Treating those as one boolean creates an audit gap.
I learned this the expensive way in a payment-adjacent system: a retry after a 429 response created two customer messages because the idempotency key lived only in the worker's memory. The fix was boring—an attempt table with a unique business key—and it made reconciliation possible. Short records beat heroic retries.
Start with the ownership constraint
Template ownership decides the shape of the backend. If support operations own the wording and attachment policy, store versioned templates and rendered artifacts in your system; if an external messaging service owns templates, your service should persist the template identifier and the exact variables used. Mixing those models leaves nobody able to reproduce a message six months later.
For a generated report, persist a notification row before enqueueing work. Give it a stable event identifier, recipient, channel, template version, report object key, and retention deadline. The report itself should be immutable, access-controlled, and referenced by checksum rather than copied into every retry payload.
The state machine can stay small:
created -> rendered -> queued -> submitted -> delivered
Failure states (render_failed, rejected, expired) are terminal for that attempt, not for the overall event. A new attempt gets a new sequence number and the same idempotency key. That distinction lets an auditor see both the original failure and the later success.
What should a Node.js notification center record for email, SMS, and polling?
Record facts at event and attempt level. The event answers “why”; the attempt answers “how.” A minimal schema looks like this:
| Record | Fields that matter | Reason |
|---|---|---|
| Event |
event_id, subject, recipient, requested_at, policy version |
Correlates the support action with its business purpose |
| Template snapshot | owner, version, locale, variable hash | Reconstructs the exact content decision |
| Attempt | channel, sequence, queued_at, submitted_at, provider reference, outcome | Supports reconciliation and retry analysis |
| Artifact | object key, SHA-256, content type, expiry | Proves which report was attached without duplicating bytes |
| Audit entry | actor, action, timestamp, reason, request ID | Makes administrative changes reviewable |
Do not put sensitive report contents into the audit log. Keep metadata there and enforce least-privilege access to the object store. For email, store the MIME assembly result or a deterministic render hash; for SMS, store the normalized text hash and segment count, not a phone number in every diagnostic line.
Polling is a read model over these records. Return a cursor, a bounded page, and a monotonic event timestamp. A client can ask for “changes after cursor C” and safely repeat the request. The server must return the same event identifiers even when a worker is still waiting on a provider callback.
Here is a compact Go example of an attempt writer. The same transaction boundary can be called by a Node.js API through an internal service, while the durable rules remain explicit:
package notify
import "context"
type Attempt struct {
EventID string
Sequence int
Channel string
IdempotencyKey string
Outcome string
}
type Store interface {
InsertAttempt(ctx context.Context, a Attempt) (bool, error)
}
// InsertAttempt must enforce a unique constraint on (event_id, channel, idempotency_key).
func RecordAttempt(ctx context.Context, s Store, a Attempt) error {
inserted, err := s.InsertAttempt(ctx, a)
if err != nil {
return err
}
if !inserted {
// A replay is acknowledged; it must not send the message again.
return nil
}
return nil
}
The important behavior is the uniqueness constraint, not the language. A Node.js implementation using PostgreSQL should make the insert and outbox enqueue one transaction; a polling endpoint should read committed rows only.
How do you make delivery history auditable without promising exactly once?
Exactly-once delivery is a useful mindset and a misleading external promise. Your database can make event creation exactly once, but an email provider or carrier may acknowledge a request after your network connection drops. Model that uncertainty explicitly with submitted_unknown, then reconcile using the provider reference or a callback. Never silently turn an unknown result into “failed” and retry blindly.
Workers need bounded retries, exponential backoff, and a dead-letter review queue. Each retry writes an attempt row before sending. Attach a correlation ID to logs, provider metadata, and the report object; redact addresses and message bodies from ordinary logs. Metrics should separate queue latency, render latency, provider acceptance, and final delivery confirmation. A single “sent” counter hides the failure mode you need to fix.
Security rules are part of correctness. OWASP's forgot-password guidance recommends uniform responses and carefully bounded, single-use codes; the same discipline applies when a report link or attachment is protected by an OTP. Expire tokens, rate-limit verification, and avoid revealing whether an account exists. For commercial email, CAN-SPAM requires accurate headers, a physical postal address, and a working opt-out method; those controls belong in the template policy, not in a post-send checklist.
A practical polling API and rollout sequence
Expose history as a stable contract, for example GET /notifications/{eventID} and GET /notifications?after={cursor}. Keep the payload provider-neutral:
{
"event_id": "evt_01J9REPORT",
"status": "partially_delivered",
"template": {"owner": "support", "version": 7},
"attempts": [
{"channel": "email", "sequence": 1, "outcome": "delivered"},
{"channel": "sms", "sequence": 1, "outcome": "submitted_unknown"}
],
"next_cursor": "eyJ0cyI6MTcyMDAwMDAwMH0="
}
A cursor should be opaque and signed or server-side stored. Cap page size, document retention, and return an explicit has_more flag. Clients must tolerate new outcome values, because delivery systems evolve.
Roll out in slices: first write events and audit entries, then enable email in shadow mode, then attach immutable reports, and finally enable SMS with a separate consent policy. Replay a captured event set in staging and compare rendered hashes. During migration, dual-write old and new records and reconcile counts by event ID; do not compare raw provider totals, which often represent attempts rather than recipients.
The catch is operational ownership. This design is not suitable when a team cannot retain encrypted artifacts, review dead letters, or honor deletion requests; choose a managed notification workflow with documented export and retention controls in that case. Keep a self-hosted ledger when regulatory evidence, template control, or cross-channel reconciliation is the deciding factor. Your choice should follow the audit boundary, not the novelty of an API.
Top comments (0)