DEV Community

mT41Gzp73rc6
mT41Gzp73rc6

Posted on

Can Pricing and Deliverability Find the Cheapest Email or SMS Event Notification Provider?

Short answer: use email for ordinary SaaS event notifications, reserve SMS for consented alerts whose delay has a real consequence, and call a provider "cheapest" only after the same US and European workload has been tested for total cost and delivery evidence. A headline rate can't settle the choice.

This architecture decision record puts four invariants ahead of any quote: one product event creates at most one intended notification per channel, retries don't create duplicates, an opted-out recipient is never contacted, and an accepted API request is never reported internally as human receipt. The application owns those guarantees. A transport provider does not.

Decision record: classify the event before choosing a transport

Routine events belong in email: completed exports, billing notices, workspace changes, and reports that retain their value after an inbox delay. SMS is an escalation path for the narrower class where delayed action has a concrete product consequence. Urgency alone isn't permission, so channel consent, destination eligibility, locale, and quiet-hour policy must be resolved before a message reaches a transport adapter.

Keep that boundary boring.

The application should create an immutable notification intent from the domain event, assign an idempotency key, and place it on a durable queue. A worker applies already-settled policy and invokes a narrow channel adapter. Later status events update an attempt record; they do not rewrite the original intent. This separation matters during a provider comparison because changing an adapter should not silently change who may be contacted or what "urgent" means.

Failure ownership then becomes legible. A growing queue age is an application or worker problem. A permanent address rejection belongs in recipient state. A retryable transport response belongs in bounded retry policy. A later delivery status is evidence about an attempt, not permission to send another copy. If these categories collapse into one failed flag, an inexpensive transport can become costly through duplicate sends, noisy support cases, and operator time.

Email deliverability also starts before submission. Google's Email sender guidelines provide a public baseline for authentication, subscription handling, and responsible sending behavior. I use those controls as admission criteria, then inspect results by message stream rather than blending receipts, account alerts, and bulk mail into one comforting average. Don't confuse them.

For SMS, permission and routing policy need the same care, but the threat model is separate. An operational alert and an authentication code may share a channel without sharing a security design. NIST SP 800-63B is the relevant starting point when a flow becomes authentication; a notification-provider ADR should not quietly make that decision on behalf of the identity system.

How should SaaS teams compare email and SMS provider pricing for event alerts?

Use a fixed traffic sample and two cost models. The email model should include monthly message volume, peak submission rate, expected retry traffic, authentication setup, bounce and complaint processing, suppression handling, status retention, and the engineering time needed to reconcile attempts. The SMS model should preserve destination country, sender type, encoding, message length, and every charge present in a current written quote. US and European traffic should remain separate because a blended average hides the route that can reverse the result.

Freeze the sample date, destination mix, templates, and acceptance criteria before asking for quotes. Public pricing pages change, and their units may not describe the same operational boundary. I'm not sure a spreadsheet can predict inbox placement or country-specific delivery from list prices; only a controlled test against the actual mix can resolve that uncertainty.

A practical shortlist might include Resend, Postmark, and SendGrid for email, plus Twilio and Plivo for SMS. Those names identify test subjects, not evidence or a recommendation; apply the same dated sample and rejection rules to each one.

Compare architecture options before comparing candidate names:

Option Operational benefit Cost or reliability risk Suitable when
One cross-channel contract One commercial relationship and access review A blended contract can conceal weak fit in one channel or destination Both channels pass the same representative test
Separate email and SMS adapters Each channel can be evaluated on its own evidence Two credentials, status formats, runbooks, and escalation paths Channel fit outweighs the extra operating work
Email with no SMS escalation Smallest policy and compliance surface No independent urgent path Every supported event tolerates inbox delay

The table is not a ranking. For each viable option, send the same permissioned test set through the complete production-shaped path. Include invalid destinations, suppressed recipients, Unicode, long content, duplicate domain events, delayed status events, and a controlled burst near the expected peak. Measure request acceptance separately from later transport status and, for email, separately from visibility in controlled mailboxes. A 202-style acceptance and a delivered notification answer different questions.

An edge case worth isolating is retry amplification. Suppose a worker receives HTTP 429, loses its lease, and another worker sees the same intent. If the idempotency key changes per attempt, the system can pay twice and notify twice even though every component behaved according to its local rule. I don't let adapter code generate that key. It comes from stable domain facts and is persisted before enqueueing.

Walk the hypothetical failure all the way through: event invoice.ready creates intent evt-1842:user-71:email, worker A submits it, and the remote side accepts the request just as the worker loses its queue lease. Worker B then receives the same intent before the later status event arrives. If B creates a fresh key, the second submission looks legitimate and the eventual status feed contains two unrelated attempt identifiers; an operator can see both but cannot prove that they came from one product decision without returning to application logs. With a persisted domain-derived key, B presents the same identity to the adapter, the internal attempt remains tied to one intent, and status ingestion can attach duplicate or out-of-order observations without manufacturing a second notification decision. This example doesn't predict how any named service handles idempotency — your mileage may vary — so that behavior belongs in the proof of concept. It does expose what the application must control before provider pricing means much: stable identity, lease-aware retries, and evidence that survives timing ambiguity.

Test the tails.

Record queue age, duplicate-intent count, permanent and transient rejection classes, suppression behavior, status reconciliation coverage, and operator minutes spent explaining unmatched attempts. Cost per accepted request is useful, but cost per reconciled notification is closer to the system the team must operate.

Put policy, idempotency, and evidence on the critical path

The critical path should be transport-neutral. This Python example selects a permitted channel, derives a stable key, and preserves the difference between submission and later evidence. Commercial routes and payload shapes belong inside tested adapter implementations, not in the domain decision.

from dataclasses import dataclass
from enum import Enum
from hashlib import sha256
from typing import Protocol


class Channel(str, Enum):
    EMAIL = "email"
    SMS = "sms"


@dataclass(frozen=True)
class NotificationIntent:
    event_id: str
    recipient_id: str
    subject: str
    body: str
    urgent: bool
    email_allowed: bool
    sms_allowed: bool


@dataclass(frozen=True)
class Submission:
    attempt_id: str
    accepted: bool


class Transport(Protocol):
    def submit(self, intent: NotificationIntent, key: str) -> Submission: ...


def choose_channel(intent: NotificationIntent) -> Channel:
    if intent.urgent and intent.sms_allowed:
        return Channel.SMS
    if intent.email_allowed:
        return Channel.EMAIL
    raise ValueError("No permitted notification channel")


def dispatch(
    intent: NotificationIntent,
    transports: dict[Channel, Transport],
) -> tuple[Channel, str, Submission]:
    channel = choose_channel(intent)
    key_material = f"{intent.event_id}:{intent.recipient_id}:{channel.value}"
    idempotency_key = sha256(key_material.encode("utf-8")).hexdigest()
    submission = transports[channel].submit(intent, idempotency_key)
    return channel, idempotency_key, submission
Enter fullscreen mode Exit fullscreen mode

Persistence wraps this function in a larger state machine. Save the intent and key before queue publication. Cap retries, add jitter, and move exhausted attempts into an operator-visible review state. Status ingestion must tolerate duplicates and out-of-order events because its job is to accumulate evidence, not to assume a perfect timeline.

Logs should carry event, attempt, template, channel, region, and outcome identifiers while excluding OTPs and full message bodies. Dashboards split enqueue success, submission acceptance, and later evidence into distinct panels. A green worker chart alongside rising queue age is not healthy; neither is a high acceptance rate with no way to reconcile downstream outcomes.

Configuration deserves an explicit preflight. I want startup validation to confirm the selected region, required credential presence, callback authentication settings, and template version without printing secret values. An HTTP 401 during a test means inspecting credential selection before rewriting serialization. Short feedback loops beat speculative fixes.

The rejected default and the cases where it still fits

I reject SMS-first for routine event notifications. The catch is that an interruptive channel is a poor default for low-urgency volume, while consent and destination policy create work that an email-only design does not need. SMS-first is not suitable when the team cannot establish valid permission, maintain opt-out state, or support the required sender route.

Still, email-only is the correct simpler option when every covered event tolerates inbox delay. A narrow SMS escalation is valid when delay has a concrete consequence and the organization can own its permission and routing rules. Neither channel should become an authentication strategy merely because it can carry a code.

There is also a valid reason to retain an incumbent transport. If it passes the representative test and the projected difference disappears after migration labor, status-pipeline work, access review, and on-call changes, switching is hard to justify. Conversely, a low quote should not rescue a candidate that cannot meet the ADR's evidence, destination, or policy criteria.

Roll out by event class and region. First shadow the channel policy without sending. Then enable controlled internal recipients and verify templates, links, deduplication, redaction, suppressions, status reconciliation, and kill switches. Expand only while queue age, duplicate counts, and later evidence remain inside the team's recorded acceptance thresholds.

Cheapest is a measured outcome, not a permanent vendor label. Choose the channel from urgency and consent, keep transport details behind narrow adapters, and repeat the same workload test whenever destination mix, message shape, or notification policy changes materially.

References

Top comments (0)