The operational constraint changes the design: an e-commerce signup notification is a compliance event before it is a messaging feature. Build the Node.js service around an immutable delivery record, a queue-backed worker, and a one-time, time-bounded verification token. Email and SMS are delivery attempts, not the source of truth.
The record is the product.
Short answer: accept a signup event, persist the minimum evidence needed to explain it, enqueue a notification job, and record every attempt with a correlation ID. Retry only transient failures, keep channel policy explicit, and advance account state only after the verification endpoint validates the token. This gives an auditor a coherent timeline without claiming that an API acknowledgement proves a person read a message.
The example is a new-account verification link. The event may choose email, SMS, or both, but that choice must be visible in the record. An SLO for request latency says little if the later evidence cannot answer a basic question: what did the system try to send, when, and why?
How should we set up a notification system using email and SMS?
Start with the smallest useful claim. The system can prove that it accepted an event, selected a channel under a stated policy, generated a token reference, attempted delivery, and received a downstream result. It should not claim successful human receipt from a transport response, and it should not store the verification token in a log merely because debugging feels easier that way.
A useful record has an event ID, privacy-preserving subject reference, channel, template version, creation time, attempt number, outcome class, and downstream correlation data where available. Store the token as a digest or opaque key reference. Keep payload retention and access policy separate from the worker; traceability is not a reason to copy personal data into every queue and log.
I initially treated the delivery response as the important evidence. That was the wrong abstraction. The response is one observation in a longer state transition, and it can be delayed, duplicated, or absent while the signup service still has to make a deterministic decision.
A failure path that stays explainable
The reliable path is deliberately boring:
- The signup transaction creates a pending account and an event record with a unique idempotency key.
- An outbox relay publishes the event after the transaction commits.
- A worker renders a versioned template and submits one channel attempt.
- The attempt is recorded before the worker decides whether to retry.
- A callback or poll result is attached to the same event, never used to overwrite history.
- The verification endpoint consumes the token once and records the state change.
If the process sends first and records later, a crash creates an unexplainable external side effect. If it records first and dies before sending, the record says prepared, not delivered; the worker can resume. At-least-once processing is acceptable when event keys and attempt semantics are explicit.
A queue is a shock absorber, not a compliance system. Size it from event rate, worker service time, retry policy, and the freshness target. If a burst produces 120 events per second and one worker handles 20 attempts per second, six active workers cover the nominal rate; I would reserve capacity for retries and partial worker loss rather than operate at 100 percent utilization. Headroom is a policy to test against the SLO, not a number to copy from a diagram.
The preventative path in Go
The production service may be written in Node.js, but the safety properties are language-independent. This worker sketch keeps transport, durable attempts, and retry classification separate.
package notification import ( "context" "errors" "time" ) type Channel string const ( Email Channel = "email" SMS Channel = "sms" ) type Attempt struct { EventID string Channel Channel Number int Outcome string RecordedAt time.Time } type Sender interface { Send(context.Context, Channel, string, string) error } type AttemptStore interface { Insert(context.Context, Attempt) error } func Deliver(ctx context.Context, sender Sender, store AttemptStore, eventID string, channel Channel, destination, body string, number int) error { err := sender.Send(ctx, channel, destination, body) outcome := "accepted" if err != nil { outcome = "failed" } recordErr := store.Insert(ctx, Attempt{EventID: eventID, Channel: channel, Number: number, Outcome: outcome, RecordedAt: time.Now().UTC()}) if recordErr != nil { return errors.Join(err, recordErr) } return err } In Node.js, the same interfaces belong behind a module boundary: a transport adapter calls an email or SMS API, while the worker owns idempotency, attempt recording, and retry classification. The adapter must not decide whether an account is verified. That decision belongs to the account service, where one-time token use can share a transaction with the state change.
Which alternative fits the evidence requirement?
The useful comparison is managed delivery versus operating the delivery control plane yourself, judged against evidence quality and operational burden.
| Decision area | Managed delivery path | Self-hosted delivery path |
|---|---|---|
| Evidence boundary | Map external message IDs and callbacks into your append-only ledger. | Own the full attempt and relay history, plus its durability and access controls. |
| On-call load | Fewer transport components; policy and callback handling remain yours. | More control over queues and adapters; transport operations expand the on-call surface. |
| Portability | Keep a provider-neutral event schema and adapter contract. | Portability is possible, but each transport still has distinct failure behavior. |
| SLO design | Measure acceptance, queue age, attempt completion, and verification separately. | The same SLOs apply, with extra relay and transport saturation signals. |
I would choose managed delivery when the team cannot staff transport operations and the evidence ledger can be independently retained. I would choose self-hosting only when a hard data, network, or control requirement justifies owning the extra failure modes. Neither choice removes the need for a durable application record.
Where the advice stops applying
This pattern is for signup notifications, not emergency paging or marketing. A security-sensitive enrollment flow may require a stronger authenticator than a link delivered by email or SMS; the channel should not silently become the account's entire identity proof. Consent, regional messaging rules, template approval, and mailbox reputation still need explicit owners and tests. The limitation is clear: this design is not appropriate when the business needs emergency delivery guarantees or when the link itself is the only required identity factor; use a stronger authentication method and a separately owned paging path in those cases. That is the trade-off for keeping signup evidence and transport operations separate.
SPF is part of that boundary. RFC 7208 defines it as a mechanism for publishing which hosts are authorized to use a domain in email transactions; it is not evidence that a recipient clicked a verification link. Keep domain policy, message authentication, delivery telemetry, and application verification as separate facts.
The success criteria are modest and testable: duplicate signup events do not create an uncontrolled duplicate workflow; a worker crash leaves a resumable state; transient transport failures are retried under a bounded policy; permanent failures are visible; and an auditor can reconstruct the event without reading secrets. That is a better target than a green send button.



