Designing a Multi-Channel Notification Platform

Imagine a customer has just paid ₹12,499 for a flight.

Within a few seconds, the platform should:

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?

A hand-drawn message router sending notification packets to phone, email, chat, and bell channels.

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:

CapabilityExamples
ChannelsEmail, SMS, push, and in-app
Delivery modesImmediate, scheduled, and bulk
PreferencesOpt-outs, quiet hours, locale, and channel priority
ReliabilityRetries, deduplication, provider failover, and dead-letter queues
Product featuresTemplates, delivery status, cancellation, and audit history
OperationsRate 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

Non-functional requirements

A quick capacity estimate

Assume the platform receives:

That gives us:

MeasurementApproximate load
Average intents580 per second
Peak intents6,000 per second
Peak channel deliveries10,800 per second
Design target with headroom20,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:

  1. Notification API validates the request and owns idempotent acceptance.
  2. Transactional outbox guarantees that a committed notification eventually reaches the router.
  3. Router applies preferences, fallbacks, quiet hours, and template selection.
  4. Channel queues isolate workloads and absorb traffic spikes.
  5. Channel workers render content and call provider adapters.
  6. Callback API converts provider-specific receipts into our delivery states.
  7. 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:

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:

DeliveryDecision
In-appSend immediately
PushSend immediately
EmailSend immediately with the ticket template
SMSKeep 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:

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:

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:

  1. API idempotency: the producer supplies an idempotency key.
  2. Stable delivery identity: each notification-channel pair has a unique delivery ID.
  3. Worker claim: only one active worker holds the delivery lease.
  4. Provider idempotency: pass the delivery ID when the provider supports it.
  5. 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.

FailureAction
Network timeoutRetry with exponential backoff and jitter
Provider returns 429Respect Retry-After and reduce concurrency
Invalid phone numberFail permanently; do not retry
Expired device tokenMark token invalid and stop
Template data missingSend to the dead-letter queue for investigation
Provider outageOpen 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

A hand-drawn notification route redirecting around a failed provider while undeliverable messages move to a holding tray.

Each channel can have multiple provider adapters. The provider registry selects one using:

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:

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:

Useful service-level indicators include:

SignalWhat it reveals
Acceptance latencyCan producers hand work to us quickly?
Queue age by channel and priorityAre customers waiting?
Time to provider acceptanceIs our delivery pipeline healthy?
Provider error rateIs a vendor degraded?
Retry and dead-letter rateAre failures becoming persistent?
Delivery receipt rateAre 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

ScenarioExpected behavior
Database is unavailableReject new requests; do not claim acceptance
Queue is unavailable after the commitOutbox publisher retries until it succeeds
One channel queue is backed upOther channel queues continue normally
Worker crashes after provider callRecover the lease and use idempotency or reconciliation
Provider is slowTimeout, open the circuit, and apply failover policy
Callback arrives twiceProcess idempotently using provider event ID
Callback arrives before worker updateUpsert or buffer it using provider message ID
Bad template reaches productionPin versions and roll back without changing queued deliveries
Region failsRoute 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

Phase 2: high volume

Phase 3: multiple regions

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:

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.