← All articlesPlatform Engineering

How to Build a Notification System: Email, Push, SMS, and Webhooks

Design and build a multi-channel notification system supporting email, push, SMS, and webhooks. Covers architecture, templates, preferences, and delivery.

Y
Yash Pritwani
14 min read

Why Notifications Are Harder Than They Look

Every application needs notifications. A user signs up — send a welcome email. An order ships — send a push notification. A payment fails — send an SMS. A teammate comments on a PR — send a webhook.

<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 170" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="170" rx="12" fill="#1a1a2e"/><circle cx="60" cy="85" r="25" fill="#f59e0b" opacity="0.85"/><text x="60" y="82" text-anchor="middle" fill="#1a1a2e" font-size="9" font-family="system-ui" font-weight="bold">Trigger</text><text x="60" y="94" text-anchor="middle" fill="#1a1a2e" font-size="8" font-family="system-ui">webhook</text><polygon points="175,55 210,85 175,115 140,85" fill="#6366f1" opacity="0.85"/><text x="175" y="88" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">If</text><rect x="250" y="35" width="100" height="40" rx="6" fill="#2dd4bf" opacity="0.85"/><text x="300" y="55" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Send Email</text><text x="300" y="67" text-anchor="middle" fill="#1a1a2e" font-size="8" font-family="system-ui">SMTP</text><rect x="250" y="95" width="100" height="40" rx="6" fill="#a855f7" opacity="0.85"/><text x="300" y="115" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Log Event</text><text x="300" y="127" text-anchor="middle" fill="#ffffff" font-size="8" font-family="system-ui">database</text><rect x="400" y="55" width="100" height="40" rx="6" fill="#3b82f6" opacity="0.85"/><text x="450" y="75" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Update CRM</text><text x="450" y="87" text-anchor="middle" fill="#ffffff" font-size="8" font-family="system-ui">API call</text><circle cx="545" cy="75" r="18" fill="none" stroke="#2dd4bf" stroke-width="2"/><text x="545" y="79" text-anchor="middle" fill="#2dd4bf" font-size="9" font-family="system-ui">Done</text><defs><marker id="arrow10" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#e2e8f0"/></marker></defs><line x1="87" y1="85" x2="138" y2="85" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><line x1="210" y1="72" x2="248" y2="55" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><line x1="210" y1="98" x2="248" y2="115" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><line x1="352" y1="55" x2="398" y2="68" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><line x1="352" y1="115" x2="398" y2="82" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><line x1="502" y1="75" x2="525" y2="75" stroke="#e2e8f0" stroke-width="1.5" marker-end="url(#arrow10)"/><text x="225" y="45" text-anchor="middle" fill="#2dd4bf" font-size="8" font-family="system-ui">true</text><text x="225" y="120" text-anchor="middle" fill="#a855f7" font-size="8" font-family="system-ui">false</text></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Workflow automation: triggers, conditions, and actions chain together to eliminate manual processes.</p></div>

Simple enough, right? Until you need: user preferences (Alice wants email, Bob wants SMS), rate limiting (do not send 50 emails in a row), templates (different languages, A/B testing), delivery tracking (was it opened?), and retry logic (what if the SMS provider is down?).

Architecture Overview

Event Source → Notification Service → Channel Router → Delivery Workers
                     ↓                                      ↓
              Template Engine                         Provider APIs
              Preference Store                    (SendGrid, Twilio, FCM)
              Rate Limiter                              ↓
                                                  Delivery Tracking

Data Model

-- Notification templates
CREATE TABLE notification_templates (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    name VARCHAR(100) UNIQUE NOT NULL,         -- e.g., 'order_shipped'
    channel VARCHAR(20) NOT NULL,              -- email, push, sms, webhook
    subject TEXT,                               -- Email subject
    body_template TEXT NOT NULL,                -- Mustache/Handlebars template
    locale VARCHAR(10) DEFAULT 'en',
    active BOOLEAN DEFAULT true,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- User notification preferences
CREATE TABLE notification_preferences (
    user_id UUID NOT NULL,
    notification_type VARCHAR(100) NOT NULL,    -- e.g., 'order_updates'
    channel VARCHAR(20) NOT NULL,               -- email, push, sms
    enabled BOOLEAN DEFAULT true,
    PRIMARY KEY (user_id, notification_type, channel)
);

-- Notification log (delivery tracking)
CREATE TABLE notification_log (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL,
    template_name VARCHAR(100) NOT NULL,
    channel VARCHAR(20) NOT NULL,
    status VARCHAR(20) DEFAULT 'pending',       -- pending, sent, delivered, failed
    provider_id VARCHAR(255),                    -- External message ID
    error_message TEXT,
    sent_at TIMESTAMPTZ,
    delivered_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ DEFAULT NOW()
);

The Notification Service

from dataclasses import dataclass
from enum import Enum

class Channel(Enum):
    EMAIL = "email"
    PUSH = "push"
    SMS = "sms"
    WEBHOOK = "webhook"

@dataclass
class NotificationRequest:
    user_id: str
    template: str
    data: dict
    channels: list[Channel] | None = None  # None = use preferences
    priority: str = "normal"  # low, normal, high, critical

class NotificationService:
    def __init__(self, db, template_engine, rate_limiter, providers):
        self.db = db
        self.templates = template_engine
        self.limiter = rate_limiter
        self.providers = providers

    async def send(self, request: NotificationRequest):
        # 1. Determine channels from preferences or override
        channels = request.channels or await self._get_preferred_channels(
            request.user_id, request.template
        )

        results = []
        for channel in channels:
            # 2. Check rate limits
            if not self.limiter.allow(request.user_id, channel):
                results.append({"channel": channel, "status": "rate_limited"})
                continue

            # 3. Render template
            template = await self.templates.get(request.template, channel)
            rendered = template.render(request.data)

            # 4. Queue for delivery
            job_id = await self._queue_delivery(
                user_id=request.user_id,
                channel=channel,
                content=rendered,
                priority=request.priority
            )
            results.append({"channel": channel, "status": "queued", "id": job_id})

        return results

Email Delivery

import httpx

class EmailProvider:
    """SendGrid email provider."""

    def __init__(self, api_key: str, from_email: str):
        self.api_key = api_key
        self.from_email = from_email
        self.base_url = "https://api.sendgrid.com/v3/mail/send"

    async def send(self, to: str, subject: str, html_body: str) -> dict:
        async with httpx.AsyncClient() as client:
            response = await client.post(
                self.base_url,
                headers={
                    "Authorization": f"Bearer {self.api_key}",
                    "Content-Type": "application/json"
                },
                json={
                    "personalizations": [{"to": [{"email": to}]}],
                    "from": {"email": self.from_email},
                    "subject": subject,
                    "content": [{"type": "text/html", "value": html_body}]
                }
            )

            if response.status_code == 202:
                return {"status": "sent", "provider_id": response.headers.get("X-Message-Id")}
            else:
                return {"status": "failed", "error": response.text}

Push Notifications (FCM)

import firebase_admin
from firebase_admin import messaging

class PushProvider:
    def __init__(self):
        firebase_admin.initialize_app()

    async def send(self, device_token: str, title: str, body: str, data: dict = None):
        message = messaging.Message(
            notification=messaging.Notification(title=title, body=body),
            data=data or {},
            token=device_token
        )

        try:
            response = messaging.send(message)
            return {"status": "sent", "provider_id": response}
        except messaging.UnregisteredError:
            # Token is invalid, remove it
            await self.remove_token(device_token)
            return {"status": "failed", "error": "unregistered_token"}
        except Exception as e:
            return {"status": "failed", "error": str(e)}

<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 180" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="180" rx="12" fill="#1a1a2e"/><rect x="20" y="20" width="70" height="35" rx="6" fill="#3b82f6" opacity="0.8"/><text x="55" y="42" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Web</text><rect x="20" y="65" width="70" height="35" rx="6" fill="#3b82f6" opacity="0.8"/><text x="55" y="87" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Mobile</text><rect x="20" y="110" width="70" height="35" rx="6" fill="#3b82f6" opacity="0.8"/><text x="55" y="132" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">IoT</text><rect x="150" y="20" width="120" height="130" rx="10" fill="#6366f1" opacity="0.9"/><text x="210" y="50" text-anchor="middle" fill="#ffffff" font-size="12" font-family="system-ui" font-weight="bold">Gateway</text><line x1="165" y1="60" x2="255" y2="60" stroke="#ffffff" stroke-width="0.5" opacity="0.3"/><text x="210" y="80" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Rate Limit</text><text x="210" y="95" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Auth</text><text x="210" y="110" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Load Balance</text><text x="210" y="125" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Transform</text><text x="210" y="140" text-anchor="middle" fill="#ffffff" font-size="9" font-family="system-ui">Cache</text><rect x="340" y="15" width="95" height="35" rx="6" fill="#a855f7" opacity="0.8"/><text x="387" y="37" text-anchor="middle" fill="#ffffff" font-size="10" font-family="system-ui">Service A</text><rect x="340" y="60" width="95" height="35" rx="6" fill="#2dd4bf" opacity="0.8"/><text x="387" y="82" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Service B</text><rect x="340" y="105" width="95" height="35" rx="6" fill="#f59e0b" opacity="0.8"/><text x="387" y="127" text-anchor="middle" fill="#1a1a2e" font-size="10" font-family="system-ui">Service C</text><rect x="490" y="55" width="80" height="45" rx="6" fill="none" stroke="#e2e8f0" stroke-width="1"/><text x="530" y="82" text-anchor="middle" fill="#e2e8f0" font-size="10" font-family="system-ui">DB / Cache</text><defs><marker id="arrow7" markerWidth="8" markerHeight="6" refX="8" refY="3" orient="auto"><path d="M0,0 L8,3 L0,6" fill="#e2e8f0"/></marker></defs><line x1="92" y1="37" x2="148" y2="55" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="92" y1="82" x2="148" y2="85" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="92" y1="127" x2="148" y2="115" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="272" y1="55" x2="338" y2="32" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="272" y1="85" x2="338" y2="77" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="272" y1="115" x2="338" y2="122" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/><line x1="437" y1="77" x2="488" y2="77" stroke="#e2e8f0" stroke-width="1" marker-end="url(#arrow7)"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">API gateway pattern: a single entry point handles auth, rate limiting, and routing to backend services.</p></div>

Webhook Delivery

Webhooks need special handling — retries, signature verification, and timeout management:

import hmac
import hashlib
import json

class WebhookProvider:
    def __init__(self, signing_secret: str):
        self.secret = signing_secret
        self.max_retries = 5
        self.retry_delays = [10, 30, 120, 600, 3600]  # seconds

    def sign_payload(self, payload: str) -> str:
        return hmac.new(
            self.secret.encode(),
            payload.encode(),
            hashlib.sha256
        ).hexdigest()

    async def send(self, url: str, event_type: str, data: dict):
        payload = json.dumps({
            "event": event_type,
            "data": data,
            "timestamp": datetime.utcnow().isoformat()
        })

        signature = self.sign_payload(payload)

        async with httpx.AsyncClient(timeout=30) as client:
            try:
                response = await client.post(
                    url,
                    content=payload,
                    headers={
                        "Content-Type": "application/json",
                        "X-Webhook-Signature": f"sha256={signature}",
                        "X-Webhook-Event": event_type
                    }
                )

                if response.status_code < 300:
                    return {"status": "delivered"}
                else:
                    return {"status": "failed", "http_status": response.status_code}

            except httpx.TimeoutException:
                return {"status": "timeout"}

Rate Limiting

Prevent notification fatigue:

class NotificationRateLimiter:
    """Per-user, per-channel rate limits."""

    LIMITS = {
        "email": {"count": 10, "window": 3600},     # 10/hour
        "push": {"count": 20, "window": 3600},       # 20/hour
        "sms": {"count": 5, "window": 86400},         # 5/day
        "webhook": {"count": 100, "window": 60},      # 100/minute
    }

    def __init__(self, redis_client):
        self.redis = redis_client

    def allow(self, user_id: str, channel: str) -> bool:
        limit = self.LIMITS.get(channel, {"count": 50, "window": 3600})
        key = f"notif_limit:{user_id}:{channel}"

        pipe = self.redis.pipeline()
        pipe.incr(key)
        pipe.expire(key, limit["window"])
        count, _ = pipe.execute()

        return count <= limit["count"]

Template Engine

from jinja2 import Environment, BaseLoader

class TemplateEngine:
    def __init__(self):
        self.env = Environment(loader=BaseLoader())

    def render(self, template_str: str, data: dict) -> str:
        template = self.env.from_string(template_str)
        return template.render(**data)

# Email template example
ORDER_SHIPPED_EMAIL = """
<h2>Your order has shipped!</h2>
<p>Hi {{ customer_name }},</p>
<p>Your order <strong>{{ order_id }}</strong> is on its way.</p>
<p>Tracking number: <a href="{{ tracking_url }}">{{ tracking_number }}</a></p>
<p>Estimated delivery: {{ delivery_date }}</p>
"""

# SMS template (keep it short)
ORDER_SHIPPED_SMS = """Your order {{ order_id }} shipped! Track: {{ tracking_url }}"""

User Preference API

@app.get("/api/notifications/preferences")
async def get_preferences(user: User = Depends(get_current_user)):
    prefs = await db.fetch_all(
        "SELECT * FROM notification_preferences WHERE user_id = $1",
        user.id
    )
    return {"preferences": prefs}

@app.put("/api/notifications/preferences")
async def update_preferences(
    updates: list[PreferenceUpdate],
    user: User = Depends(get_current_user)
):
    for update in updates:
        await db.execute("""
            INSERT INTO notification_preferences (user_id, notification_type, channel, enabled)
            VALUES ($1, $2, $3, $4)
            ON CONFLICT (user_id, notification_type, channel)
            DO UPDATE SET enabled = $4
        """, user.id, update.type, update.channel, update.enabled)

    return {"status": "updated"}

<div style="margin:2.5rem auto;max-width:600px;width:100%;text-align:center;"><svg viewBox="0 0 600 200" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:auto;"><rect width="600" height="200" rx="12" fill="#1a1a2e"/><text x="80" y="25" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Input</text><circle cx="80" cy="50" r="14" fill="none" stroke="#3b82f6" stroke-width="2"/><circle cx="80" cy="100" r="14" fill="none" stroke="#3b82f6" stroke-width="2"/><circle cx="80" cy="150" r="14" fill="none" stroke="#3b82f6" stroke-width="2"/><text x="230" y="25" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Hidden</text><circle cx="230" cy="45" r="14" fill="#6366f1" opacity="0.8"/><circle cx="230" cy="85" r="14" fill="#6366f1" opacity="0.8"/><circle cx="230" cy="125" r="14" fill="#6366f1" opacity="0.8"/><circle cx="230" cy="165" r="14" fill="#6366f1" opacity="0.8"/><text x="380" y="25" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Hidden</text><circle cx="380" cy="55" r="14" fill="#a855f7" opacity="0.8"/><circle cx="380" cy="100" r="14" fill="#a855f7" opacity="0.8"/><circle cx="380" cy="145" r="14" fill="#a855f7" opacity="0.8"/><text x="520" y="25" text-anchor="middle" fill="#94a3b8" font-size="10" font-family="system-ui">Output</text><circle cx="520" cy="80" r="14" fill="none" stroke="#2dd4bf" stroke-width="2"/><circle cx="520" cy="130" r="14" fill="none" stroke="#2dd4bf" stroke-width="2"/><line x1="94" y1="50" x2="216" y2="45" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="50" x2="216" y2="85" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="50" x2="216" y2="125" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="50" x2="216" y2="165" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="100" x2="216" y2="45" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="100" x2="216" y2="85" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="100" x2="216" y2="125" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="100" x2="216" y2="165" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="150" x2="216" y2="45" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="150" x2="216" y2="85" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="150" x2="216" y2="125" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="94" y1="150" x2="216" y2="165" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="45" x2="366" y2="55" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="45" x2="366" y2="100" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="45" x2="366" y2="145" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="85" x2="366" y2="55" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="85" x2="366" y2="100" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="85" x2="366" y2="145" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="125" x2="366" y2="55" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="125" x2="366" y2="100" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="125" x2="366" y2="145" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="165" x2="366" y2="55" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="165" x2="366" y2="100" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="244" y1="165" x2="366" y2="145" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="55" x2="506" y2="80" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="55" x2="506" y2="130" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="100" x2="506" y2="80" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="100" x2="506" y2="130" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="145" x2="506" y2="80" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/><line x1="394" y1="145" x2="506" y2="130" stroke="#e2e8f0" stroke-width="0.5" opacity="0.3"/></svg><p style="margin-top:0.75rem;font-size:0.85rem;color:#94a3b8;font-style:italic;line-height:1.4;">Neural network architecture: data flows through input, hidden, and output layers.</p></div>

Delivery Monitoring

Track delivery rates per channel in Grafana:

SELECT
  channel,
  COUNT(*) FILTER (WHERE status = 'delivered') * 100.0 / COUNT(*) AS delivery_rate,
  COUNT(*) FILTER (WHERE status = 'failed') AS failures,
  AVG(EXTRACT(EPOCH FROM (delivered_at - created_at))) AS avg_delivery_seconds
FROM notification_log
WHERE created_at >= NOW() - INTERVAL '24 hours'
GROUP BY channel;

A well-built notification system is a competitive advantage. Users who get timely, relevant notifications on their preferred channel are more engaged and more loyal. At TechSaaS, we build notification infrastructure as part of our platform engineering services.

#notifications#email#webhooks#architecture#platform-engineering

Need help with platform engineering?

TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.