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.
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 TrackingData 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 resultsEmail 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.
Need help with platform engineering?
TechSaaS provides expert consulting and managed services for cloud infrastructure, DevOps, and AI/ML operations.