TL;DR: Put bounce classification and recipient suppression in your application, then choose the least complex transport arrangement that meets your reliability needs. A single email-and-SMS provider reduces integration work. Separate specialists give each channel its own failure boundary. Neither choice should own the only copy of recipient state. For a B2B SaaS team, the durable design is a small control plane that decides whether an event may become a delivery attempt.
| Operating model | Pick it when | Reliability cost | First thing to verify |
|---|---|---|---|
| One provider for both channels | A small team needs one webhook contract and one operational relationship | A provider-wide control-plane issue can affect both paths | Email and SMS failures remain distinguishable in events and metrics |
| Separate email and SMS providers | Each channel has different owners, policies, or reliability targets | Two callback models and two reconciliation paths must be maintained | Both adapters map into the same internal failure vocabulary |
| Primary plus standby transport | Notification loss has a defined business impact that justifies extra machinery | Failover can duplicate messages or bypass suppression | Idempotency, suppression, and recovery drills work before traffic switches |
The table is the decision. Start with one provider when the team cannot yet operate two integrations well. Split only after a concrete channel requirement or failure boundary pays for the extra moving parts. Do not use price as the deciding signal; a cheap send that repeatedly targets an invalid address is operational debt.
Should an event notification stack use one provider or separate vendors?
A combined provider is the shortest route from an application event to two delivery channels. There is one adapter shape to learn, fewer credentials to rotate, and one callback ingestion surface to monitor. That smaller surface matters for a startup. Every integration adds queues, signatures, retry behavior, dashboards, and an on-call runbook.
Start there.
Pick this model when email and SMS share an owner and a reliability target. It is also sensible while volume is low enough that the team learns more from clean instrumentation than from provider specialization. The important constraint is ownership: the provider reports attempts, but the application owns notification intent and recipient eligibility.
Separate specialists create two transport failure domains. Pick them when the email program and SMS program have materially different requirements, release cadences, or operational owners. The split can also make a channel migration smaller because the application already treats transports as adapters.
There is a real cost. A "bounce" is an email concept, while an SMS delivery failure arrives with a different vocabulary. If each callback writes directly into product tables, the application accumulates channel-specific branches. Soon, "do not contact this destination" means three different things in three services. That is where delivery reliability starts to drift.
Names matter.
A primary-and-standby arrangement is a third, serious option, but it is not a default. Pick it only when the business has defined the events eligible for failover and the team can test recovery. Switching transports without a stable attempt ID can emit duplicates. Switching without consulting suppression can retry a destination that the system already classified as invalid. More providers do not create reliability by themselves.
Keep one decision point above every transport
Picture the flow from left to right. A billing or account event enters an outbox. A notification worker reads it and asks the recipient policy store one question: may this destination receive this message on this channel? If yes, the worker creates an attempt and hands it to an adapter. Later, a callback normalizer converts the transport result into a small internal event. The policy store consumes that event, while metrics and logs consume a copy.
That is the whole diagram. The control plane stays yours.
The key distinction is between a notification, an attempt, and a result. A notification is the business intent, such as "invoice ready." An attempt is one handoff to one channel. A result is later evidence about that attempt. Keeping those records separate lets a worker retry a temporary transport problem without inventing another business event. It also makes an operator able to answer a basic question: did the system decide not to send, or did a send fail?
Recipient policy should be channel-scoped. An invalid email address must not automatically block a valid phone number. Likewise, an SMS failure should not erase permission to send account email. A broader account-level block can still exist, but it should be an explicit product policy instead of an accidental side effect of a callback.
For email authentication, DKIM gives a signing domain a way to take responsibility for a message by attaching a cryptographic signature. RFC 6376 also explains that verification failure does not, by itself, force a particular acceptance or rejection action. Treat authentication evidence and destination failure as separate inputs. Conflating them produces bad suppression decisions.
Build the suppression loop before the second adapter
Start with a narrow internal contract. The transport adapter accepts an immutable attempt. The callback normalizer returns a channel, destination key, outcome class, and original attempt ID. It does not update recipient policy directly. That separation makes replay safe and keeps provider vocabulary at the edge.
type Channel = "email" | "sms";
type Outcome = "accepted" | "delivered" | "temporary_failure" | "permanent_failure";
type DeliveryAttempt = {
id: string;
notificationId: string;
channel: Channel;
destinationKey: string;
createdAt: string;
};
type DeliveryResult = {
attemptId: string;
channel: Channel;
destinationKey: string;
outcome: Outcome;
observedAt: string;
sourceEventId: string;
};
interface TransportAdapter {
send(attempt: DeliveryAttempt): Promise<{ externalId: string }>;
}
interface RecipientPolicy {
maySend(channel: Channel, destinationKey: string): Promise<boolean>;
record(result: DeliveryResult): Promise<void>;
}
Keep the normalized outcome set deliberately small. Store the original callback payload in access-controlled retention if audit requirements call for it, but do not leak every external status into business logic. A new adapter then has one demanding job: map its events accurately.
Suppression needs an idempotent write. Callback systems retry. Events can arrive out of order. The pair of source and event ID should therefore be unique in the ingestion ledger, and recording the same result again should not deepen or extend a suppression. This is also why the attempt ID travels through the adapter rather than being reconstructed from an address and timestamp.
Retries happen.
async function ingestResult(
result: DeliveryResult,
policy: RecipientPolicy,
seen: Set<string>,
): Promise<"recorded" | "duplicate"> {
const dedupeKey = `${result.channel}:${result.sourceEventId}`;
if (seen.has(dedupeKey)) return "duplicate";
await policy.record(result);
seen.add(dedupeKey);
return "recorded";
}
The in-memory set makes the contract visible, not production-ready. In a deployed service, the uniqueness check and policy mutation belong in a durable transaction or in an equivalent atomic processing design. Otherwise a process can fail after changing policy but before marking the event as consumed. The retry then applies the event twice. Walk through the awkward ordering before deployment: an attempt is accepted, a permanent-failure callback changes recipient policy, and then a delayed temporary-failure callback arrives. The older result must remain useful evidence without reopening the destination. Now replay the permanent failure. Nothing should change. Finally, send a new business event to the same recipient and confirm that policy stops it before any adapter call. This sequence is more revealing than a happy-path send because it crosses the event ledger, policy store, queue, and adapter boundary in a few steps.
Do not suppress on every failure. The normalized class exists because a temporary failure and a permanent failure demand different action. The exact mapping must come from the transport's documented event semantics and your product policy. Preserve the raw reason for investigation, map it once at the boundary, and test that mapping with fixtures. Guessing from free-form text is brittle.
This is the implementation seam worth spending time on. Adapter code is usually obvious. Correctly linking asynchronous evidence back to an attempt is what makes the system explainable at 03:00.
Make failures visible before they become reputation damage
A dashboard should follow the same state machine as the code. Count notification intents, policy suppressions, attempted sends, accepted handoffs, deliveries where that signal exists, temporary failures, permanent failures, duplicate callbacks, and unmapped callbacks. Keep channel and outcome as bounded dimensions. Do not put destination addresses, phone numbers, attempt IDs, or raw reason strings into metric labels.
The first useful ratio is suppressed destinations divided by evaluated destinations, grouped by channel and notification class. A sudden change can reveal stale customer data or a broken import before the application keeps attempting known-bad destinations. Another useful view is callback age: the time from attempt creation to observed result. Plot the distribution. Averages can hide a long tail.
Logs answer individual questions. Include notification ID, attempt ID, channel, adapter name, normalized outcome, and a correlation-safe destination key. Exclude message bodies and raw recipient values. One structured log line at each state transition is easier to follow than several prose messages around a network call.
Alerts should point to an action. Alert on an inability to make suppression decisions, sustained growth in unmapped outcomes, or a stopped callback stream while attempts continue. A page for every individual bounce is noise. A page because the policy store cannot be read has a runbook: pause affected dispatch, protect the queue, restore the decision path, and reconcile results.
Test the quiet path too. Feed a permanent-failure fixture twice and confirm one policy change. Deliver an older temporary-failure event after a newer permanent failure and confirm policy does not reopen. Mark email invalid and confirm SMS remains eligible. Then disconnect one adapter in a staging drill and verify that queued work, metrics, and recovery behave as designed. These tests compare architectures more honestly than counting SDK methods.
Limits and the practical choice
This model has limits.
The trade-off is extra application state and another decision path on every send. This control plane is not suited to a prototype where notifications are disposable and nobody will operate a policy store. It also cannot make downstream delivery observable when a channel supplies no final signal, decide product consent policy, set regional messaging rules, choose retention periods, or determine which failures deserve manual review. Those are inputs the team must define and own. Avoid a standby transport when the team cannot rehearse failover; an untested second path adds ambiguity rather than reliability.
Choose one provider when operational simplicity is the binding constraint. Choose separate providers when channel requirements or failure isolation are concrete enough to justify two integrations. Add standby delivery only after idempotency and suppression survive a recovery drill. In every case, keep recipient eligibility, attempt identity, and normalized results outside the transport boundary. That is the part that preserves reliability as the stack changes.
Further reading
- RFC 6376, DomainKeys Identified Mail (DKIM): https://datatracker.ietf.org/doc/html/rfc6376
- Anthropic tool-use overview, for guidance on explicit tool schemas and descriptions: https://platform.claude.com/docs/en/agents-and-tools/tool-use/overview
Top comments (0)