Designing a Multi-Channel Notification Platform
Imagine a customer has just paid ₹12,499 for a flight.
Within a few seconds, the platform should:
- show a confirmation inside the app;
- send a push notification;
- email the ticket; and
- use SMS only if the push notification cannot be delivered.
That sounds like four API calls. It is not.
The email provider may be slow. The customer may have disabled push notifications. The SMS provider may accept a request and still fail to deliver it. A producer may retry the original request. A marketing campaign may arrive at the same time and consume every available worker.
The interesting system-design question is therefore not “How do we send a message?” It is:
How do we accept one notification intent, turn it into the right channel deliveries, and keep each delivery reliable, observable, and independent?

What are we building?
The platform accepts a notification intent from another service. It decides who should receive it, which channels are allowed, which template and locale to use, and when each message should be sent.
We will support:
| Capability | Examples |
|---|---|
| Channels | Email, SMS, push, and in-app |
| Delivery modes | Immediate, scheduled, and bulk |
| Preferences | Opt-outs, quiet hours, locale, and channel priority |
| Reliability | Retries, deduplication, provider failover, and dead-letter queues |
| Product features | Templates, delivery status, cancellation, and audit history |
| Operations | Rate limits, dashboards, tracing, and replay |
We will not build the email, SMS, or push networks themselves. External providers handle the final mile. Our responsibility ends after we track the provider response and, where supported, process its delivery callbacks.
Start with the contract
A producer should describe what happened, not how every channel should format it.
POST /v1/notifications
Idempotency-Key: booking-8472-confirmed-v1
{
"type": "BOOKING_CONFIRMED",
"recipientId": "user_42",
"data": {
"bookingId": "booking_8472",
"destination": "Singapore",
"departureTime": "2027-01-08T04:30:00Z"
},
"channels": ["IN_APP", "PUSH", "EMAIL"],
"fallback": ["PUSH", "SMS"],
"priority": "TRANSACTIONAL"
}
The response acknowledges durable acceptance, not delivery:
{
"notificationId": "ntf_01JZ...",
"status": "ACCEPTED"
}
Why return early? A provider call could take seconds, and one intent may fan out into several independent deliveries. The synchronous API should validate and persist the request; asynchronous workers should do the slow work.
Requirements that shape the design
Functional requirements
- Accept individual and bulk notification requests.
- Resolve recipient preferences and contact details.
- Render versioned, localized templates.
- Deliver through multiple providers per channel.
- Support scheduled delivery and quiet hours.
- Track the status of every channel independently.
- Retry transient failures and expose permanent failures.
- Process delivery receipts from providers.
Non-functional requirements
- Availability: accepting an important notification should not depend on a provider being online.
- Isolation: an email outage must not stop push or SMS delivery.
- At-least-once processing: losing a notification is worse than processing it twice internally.
- Effectively-once user experience: duplicates should be prevented at the boundaries we control.
- Low latency: 99% of transactional notifications should be handed to a provider within 10 seconds.
- Auditability: operators must be able to explain what happened to any notification.
A quick capacity estimate
Assume the platform receives:
- 50 million notification intents per day;
- an average of 1.8 channel deliveries per intent; and
- a peak load ten times the daily average.
That gives us:
| Measurement | Approximate load |
|---|---|
| Average intents | 580 per second |
| Peak intents | 6,000 per second |
| Peak channel deliveries | 10,800 per second |
| Design target with headroom | 20,000 deliveries per second |
This is large enough to require partitioned queues and horizontally scalable workers, but not so large that we need exotic infrastructure on day one.
High-level design
The main components are deliberately separated:
- Notification API validates the request and owns idempotent acceptance.
- Transactional outbox guarantees that a committed notification eventually reaches the router.
- Router applies preferences, fallbacks, quiet hours, and template selection.
- Channel queues isolate workloads and absorb traffic spikes.
- Channel workers render content and call provider adapters.
- Callback API converts provider-specific receipts into our delivery states.
- Notification store keeps intent, delivery, attempt, and audit records.
Follow one notification through the system
Let us trace the flight confirmation.
1. Accept it once
The API validates the event type and required template data. In one database transaction, it inserts:
- the notification intent;
- the supplied idempotency key; and
- an outbox event.
If the producer times out and repeats the request with the same key, the API returns the original notificationId.
2. Build the delivery plan
The router loads the recipient's preferences:
In-app: enabled
Push: enabled, preferred
Email: enabled
SMS: transactional messages only
Quiet hours: 22:00–07:00 local time
Locale: en-IN
It turns one intent into three initial delivery records:
| Delivery | Decision |
|---|---|
| In-app | Send immediately |
| Push | Send immediately |
| Send immediately with the ticket template | |
| SMS | Keep as fallback for push failure |
Marketing messages would wait until quiet hours end. A booking confirmation is transactional, so it can bypass that delay.
3. Isolate each channel
The router publishes each delivery to its channel queue. Push can now succeed even if the email provider is timing out.
Separate queues also allow different operating policies:
- SMS workers can enforce an expensive per-minute quota.
- Email workers can batch low-priority campaigns.
- Push workers can maintain many lightweight connections.
- Transactional traffic can have reserved worker capacity.
4. Render late, but version safely
The worker renders the template just before delivery so it can use the latest recipient contact details and locale.
However, “latest template” is dangerous: a retry tomorrow should not silently produce different legal copy. Each delivery therefore records a templateVersion when the plan is created.
5. Send and observe
The worker calls the selected provider, stores the provider message ID, and marks the delivery PROVIDER_ACCEPTED.
Later, a provider callback may move it to DELIVERED, BOUNCED, or UNDELIVERABLE.
The data model
Do not store everything in one notifications row. An intent and its channel deliveries have different lifecycles.
This separation answers useful questions cleanly:
- Was the notification accepted?
- Which channels were selected?
- Did the email succeed while SMS failed?
- Which provider handled attempt three?
- What content version did the customer receive?
For high-volume systems, old attempts can move to cheaper analytical storage while recent operational records remain in the primary database.
Low-level design: channel workers
Every channel has different rules, but the processing pipeline should remain consistent.
interface ChannelAdapter {
send(message: RenderedMessage, context: SendContext): Promise<ProviderResult>;
classify(error: unknown): FailureKind;
}
type FailureKind = "TRANSIENT" | "RATE_LIMITED" | "INVALID_DESTINATION" | "PERMANENT";
class DeliveryWorker {
constructor(
private deliveries: DeliveryRepository,
private templates: TemplateRenderer,
private providers: ProviderRegistry,
private retryPolicy: RetryPolicy
) {}
async process(job: DeliveryJob) {
const delivery = await this.deliveries.claim(job.deliveryId);
if (!delivery || delivery.isTerminal()) return;
const message = await this.templates.render(delivery);
const adapter = this.providers.select(delivery.channel);
try {
const result = await adapter.send(message, delivery.sendContext());
await this.deliveries.markAccepted(delivery.id, result);
} catch (error) {
await this.retryPolicy.handle(delivery, adapter.classify(error));
}
}
}
The worker depends on a ChannelAdapter, not a specific vendor SDK. Adding a second SMS provider should require a new adapter and routing configuration—not a rewrite of the delivery pipeline.
The claim operation is important. It uses a short lease or an atomic state transition so two workers cannot actively process the same delivery at the same time.
Delivery states
“Sent” is too vague to be useful. Did our worker call the provider? Did the provider accept it? Did the phone receive it?
For providers without final delivery receipts, PROVIDER_ACCEPTED may be the strongest truth available. The platform should report that honestly instead of calling it delivered.
At-least-once processing without annoying users
Queues normally provide at-least-once delivery. A worker can call an SMS provider successfully and crash before acknowledging the queue message. The message then returns, and another worker processes it again.
We reduce duplicates at several layers:
- API idempotency: the producer supplies an idempotency key.
- Stable delivery identity: each notification-channel pair has a unique delivery ID.
- Worker claim: only one active worker holds the delivery lease.
- Provider idempotency: pass the delivery ID when the provider supports it.
- Attempt ledger: record every provider call and response.
There is still a narrow uncertainty window with providers that do not support idempotency. Pretending we have exactly-once delivery would hide that trade-off. In such cases, the worker should query the provider by correlation ID if possible before sending again.
Retries are a policy, not a loop
Not every failure deserves another attempt.
| Failure | Action |
|---|---|
| Network timeout | Retry with exponential backoff and jitter |
Provider returns 429 | Respect Retry-After and reduce concurrency |
| Invalid phone number | Fail permanently; do not retry |
| Expired device token | Mark token invalid and stop |
| Template data missing | Send to the dead-letter queue for investigation |
| Provider outage | Open circuit and route to the secondary provider |
A possible backoff sequence is 10 seconds, 30 seconds, 2 minutes, 10 minutes, and 1 hour. Jitter prevents thousands of delayed jobs from waking at exactly the same moment.
Retries also need an expiry. A one-time password delivered two hours late is not a success. Each notification type should define a maximum useful lifetime.
Provider failover

Each channel can have multiple provider adapters. The provider registry selects one using:
- channel and destination region;
- current health and latency;
- price and contractual limits;
- message type; and
- data-residency requirements.
A circuit breaker removes an unhealthy provider from normal selection after repeated failures. A limited number of probe requests determine when it can return.
Failover must still respect idempotency. If provider A times out, we may not know whether it accepted the message. Immediately sending through provider B could produce a duplicate. The correct decision depends on message urgency, provider lookup capabilities, and the cost of duplication.
For a fraud alert, a possible duplicate may be acceptable. For a one-time password, it may be confusing or unsafe. This should be an explicit per-notification policy.
Protect transactional traffic from campaigns
Suppose marketing schedules 20 million promotional emails at 9:00 AM. A password-reset email arriving at 9:01 AM should not wait behind them.
Use separate priority lanes:
P0 Security: OTP, suspicious login, account recovery
P1 Transactional: payment, booking, order status
P2 Product: reminders, collaboration updates
P3 Marketing: promotions and campaigns
Workers can use weighted scheduling—for example, process eight transactional jobs, then two bulk jobs—while reserving minimum capacity for P0 and P1.
Priority alone is not enough. Add per-tenant quotas so one customer cannot monopolize a shared platform, and per-provider token buckets so workers do not exceed vendor limits.
Ordering: preserve only what matters
Global ordering would reduce throughput and add coordination for little benefit. Most notifications are independent.
Ordering matters within a narrow key. A customer should not receive “Your order was shipped” before “Your order was confirmed.”
Partition those messages by a stable key such as orderId. All events for the same order reach the same queue partition, while unrelated orders continue in parallel.
Even then, delayed retries can break apparent order. The consumer should include an event version or sequence number and suppress obsolete messages when the product semantics require it.
Preferences and compliance belong before delivery
Preferences should be evaluated while building the delivery plan, not deep inside a provider adapter.
The policy engine considers:
- notification category;
- explicit channel opt-ins and opt-outs;
- legally required messages;
- local quiet hours;
- locale and time zone;
- verified contact destinations; and
- frequency caps.
Keep a snapshot of the decision inputs with the delivery. This creates an audit trail for questions such as, “Why did this customer receive an SMS at 2:00 AM?”
Observability: follow one message, not just a server
CPU and memory graphs will not tell support why a customer's ticket never arrived.
Every log, metric, and trace should carry:
notificationId;deliveryId;- notification type;
- channel;
- provider; and
- tenant or product identifier.
Useful service-level indicators include:
| Signal | What it reveals |
|---|---|
| Acceptance latency | Can producers hand work to us quickly? |
| Queue age by channel and priority | Are customers waiting? |
| Time to provider acceptance | Is our delivery pipeline healthy? |
| Provider error rate | Is a vendor degraded? |
| Retry and dead-letter rate | Are failures becoming persistent? |
| Delivery receipt rate | Are accepted messages reaching devices? |
An operator should be able to enter notificationId and see the complete story from API acceptance to the last provider callback.
Failure scenarios worth designing first
| Scenario | Expected behavior |
|---|---|
| Database is unavailable | Reject new requests; do not claim acceptance |
| Queue is unavailable after the commit | Outbox publisher retries until it succeeds |
| One channel queue is backed up | Other channel queues continue normally |
| Worker crashes after provider call | Recover the lease and use idempotency or reconciliation |
| Provider is slow | Timeout, open the circuit, and apply failover policy |
| Callback arrives twice | Process idempotently using provider event ID |
| Callback arrives before worker update | Upsert or buffer it using provider message ID |
| Bad template reaches production | Pin versions and roll back without changing queued deliveries |
| Region fails | Route new traffic to another region and resume from replicated state |
The outbox pattern is doing important work here. Without it, the API could commit the notification but fail to publish the queue message, leaving a record that will never be processed.
How the design scales
Phase 1: one region
- Stateless API instances behind a load balancer.
- Relational database for notification and delivery state.
- Transactional outbox publisher.
- Managed partitioned queues.
- Autoscaled worker pools per channel.
- Redis for rate-limit counters and short-lived coordination.
Phase 2: high volume
- Partition deliveries by recipient or aggregate identity.
- Separate transactional and bulk infrastructure.
- Archive old attempts to object storage or an analytical database.
- Introduce adaptive provider routing based on live performance.
Phase 3: multiple regions
- Keep recipient data in its required home region.
- Accept and process notifications close to that region.
- Use globally unique IDs and region-aware idempotency keys.
- Avoid synchronous cross-region calls in the delivery path.
- Replicate only the minimum state required for disaster recovery.
Active-active delivery is possible, but it makes duplicate prevention and ordering harder. For many systems, regional ownership with controlled failover is a better first design.
The design decisions that matter most
The platform is not made reliable by adding more providers. Reliability comes from the boundaries between components:
- Accept the intent and outbox event in one transaction.
- Expand one intent into independently tracked channel deliveries.
- Isolate channels and priorities with separate queues.
- Treat retries, fallbacks, and expiration as explicit policies.
- Record attempts without overstating what “delivered” means.
- Design every external boundary for duplicates and uncertainty.
The final architecture is less like a function that sends four messages and more like a small logistics network. It accepts a package once, chooses the permitted routes, isolates congestion, records every handoff, and has a plan for when the road ahead is closed.
That is what turns notifications from scattered API calls into a platform.