TL;DR: For an e-commerce signup, treat the verification request as a durable event and the email or SMS send as a retryable side effect. Persist the decision, an evidence reference, and a delivery attempt before calling either channel. That gives the compliance reviewer a timeline instead of a hopeful log line.
The practical design is small: one event schema, one outbox, two channel workers, and an idempotency key derived from the signup verification request. The provider can change. The evidence should not.
Keep it boring.
What evidence does a verification event need?
A message being accepted by an email or SMS service is not proof that a person verified an account. It is one step in a chain. For each signup, record the request time, the account or signup identifier, the destination in a privacy-conscious form, the channel selected, the template version, the event identifier, and the eventual verification result. Keep the raw message content out of this trail unless a retention policy explicitly requires it.
I started with a simpler assumption: the application log would be enough. It was not. Logs answer what the process printed. They do not reliably connect a signup decision to a queued event, a retry, a provider response, and a verification click. An append-only event record gives those steps a stable relationship.
The channel worker should also record state transitions, not only failures. queued, sent, delivered, failed, and expired mean different things operationally. Email delivery telemetry and SMS delivery telemetry are not interchangeable, so store the channel-specific response as structured metadata beside a normalized state. Redact destination values and access tokens in routine logs.
That boundary matters.
For email, domain authentication is part of the delivery design. SPF is documented in RFC 7208, but it is only one mechanism in a broader sender-authentication and reputation program. Treat DNS configuration as an owned deployment input, test it before launch, and keep the sending identity separate from the application event model.
How should SaaS apps handle email and SMS notification events?
The application request should create the verification challenge and the notification event in one database transaction. A worker then claims outbox rows and calls a channel adapter. The adapter knows how to send. It should not decide whether an account may be created, generate a second challenge, or write compliance records behind the caller's back.
Here is the smallest useful TypeScript shape. It has deliberately boring interfaces. Boring interfaces make changing transport less expensive.
type Channel = "email" | "sms";
type DeliveryState = "queued" | "sent" | "delivered" | "failed" | "expired";
type VerificationEvent = {
id: string;
signupId: string;
channel: Channel;
destination: string;
templateVersion: string;
idempotencyKey: string;
createdAt: string;
};
type DeliveryResult = {
state: DeliveryState;
providerReference?: string;
metadata?: Record<string, string>;
};
interface ChannelSender {
send(event: VerificationEvent): Promise<DeliveryResult>;
}
interface Outbox {
claim(limit: number): Promise<VerificationEvent[]>;
recordResult(eventId: string, result: DeliveryResult): Promise<void>;
}
The transaction that creates a signup should insert both the challenge and its outbox event. If the transaction rolls back, there is no notification to send. If the worker crashes after the remote call but before recordResult, the idempotency key lets the adapter or transport layer handle the duplicate case according to its documented contract. Do not pretend every provider offers the same guarantee; make that boundary visible in the adapter.
A worker loop needs bounded retries and a dead-letter state. Retry a timeout or a temporary upstream response. Do not retry an invalid destination forever. A verification link has an expiry, so a delayed message is a correctness problem, not merely a latency problem. Check the challenge state before sending and again before accepting the verification action.
I first assumed a retry count was the important setting. Later I found the more useful question was what the application knows after the retry. A timeout can mean the remote system accepted the message, rejected it, or never saw it. Wiring that ambiguity into a failed label and trying again can produce two valid messages. The safer practice is to preserve unknown in the attempt record, use the idempotency key where the transport supports it, and make the next action explicit.
async function dispatch(outbox: Outbox, sender: ChannelSender): Promise<void> {
const events = await outbox.claim(50);
for (const event of events) {
try {
const result = await sender.send(event);
await outbox.recordResult(event.id, result);
} catch (error) {
await outbox.recordResult(event.id, {
state: "failed",
metadata: {
errorType: error instanceof Error ? error.name : "unknown",
},
});
}
}
}
The batch size is an example, not a benchmark. Measure it in the actual workload. A large batch can improve database efficiency while increasing claim time and making shutdown less tidy. A small batch reduces the blast radius of a bad deployment but may leave the queue behind. This is one of those knobs that deserves a metric before a confident number. These practices apply to small SaaS apps as well as larger systems; the tables and workers can start in one service and separate later.
What happens when email and SMS disagree?
The two channels should share the verification state machine, not a fake notion of identical delivery. Email can remain queued while SMS is delivered. One can fail because its destination is malformed; the other can be throttled. The account should not become verified until the user completes the verification action, regardless of which channel carried the link or code.
Choose a primary channel from policy and user consent. A fallback is a new delivery attempt with a reason, not an invisible second send. Record that reason because duplicate notifications are confusing to users and difficult to explain during review. If the user requests another message, create a new challenge or explicitly supersede the old one. Never allow several live tokens to have equal authority without a clear rule.
This is also where event ordering matters. A resend event can overtake the first send in a queue. The verification endpoint should validate token status and expiry from the database, rather than trusting message order. Delivery callbacks should be authenticated and correlated by an opaque event identifier; they should not be allowed to mutate an unrelated signup based on a destination string alone.
A useful test matrix includes a worker crash after send, a duplicate callback, a timeout with an unknown remote result, an expired challenge, a user requesting both channels, and a provider response that is syntactically successful but contains no usable reference. Those cases are more valuable than a happy-path test that asserts one mocked send() call.
Consider the review trail six weeks later: the signup exists, the first worker attempt timed out, a retry was queued, the user clicked a link, and the callback arrived twice. Without event identifiers and state transitions, those records look like separate stories. With them, the reviewer can connect the challenge, the two attempts, the callback, and the final verification decision without reading application logs line by line or relying on a destination address as a key. That is the real purpose of the outbox here. It is not a fashionable queue pattern; it is a compact explanation of what the system decided and what happened afterward.
What would I change at scale?
I would split the evidence store from operational logs, encrypt sensitive fields with a key-rotation plan, and put retention periods beside each event type. I would add metrics for queue age, time from request to send, delivery-state distribution, verification completion, retry count, and expired challenges. I would alert on queue age and verification completion shifts, not on raw send volume. Volume follows traffic; the ratios reveal trouble.
I would also make template versions immutable. Editing a template in place makes an old delivery impossible to reconstruct accurately. A version identifier plus a small set of rendered-message metadata is enough for most investigations, while keeping the evidence record narrower than a message archive. Validate links before enqueueing them: use the expected origin, avoid open redirects, and keep tokens short-lived and single-use.
The trade-off is obvious. More state means more schema and retention work. Less state means a fast first implementation and a painful answer when a customer asks why an account was verified, which message was sent, or why a resend did not work. For a compliance-sensitive signup flow, I would pay for the explicit state. The notification adapter is cheap to replace; missing evidence is not.
Top comments (0)