Notification as a Service (NaaS) — a scalable, multi-tenant, multi-channel notification platform built with FastAPI.
Features · Architecture · Quick Start · API · Development · Docker · Testing · Configuration
Notify Service is an MVP Notification as a Service platform. Tenants authenticate with API keys, manage end-users and devices, and send notifications across channels. Delivery is asynchronous, idempotent, and tracked with full status history.
| Channel | Status |
|---|---|
| Email (SMTP) | Implemented |
| Push (FCM / devices) | Future |
| SMS | Future |
Stack highlights: Python 3.13 · FastAPI · SQLAlchemy 2 · PostgreSQL · Redis · Docker multi-stage ·
- Multi-tenant isolation — every resource is scoped by tenant via
X-API-Key - Secure API keys — format
sk_<env>_<random>, stored as HMAC-SHA256 + server pepper; prefix indexed for O(1) lookup; constant-time verify - Users & devices — external IDs unique per tenant; soft-delete; device registration (push delivery is future)
- Email notifications — async create (
202 Accepted), background delivery, retries with exponential backoff - Idempotency — optional
Idempotency-Keyheader backed by Redis (24h TTL by default) - Cross-channel notification model — shared table + JSONB
payloadfor channel-specific fields - Health probes — liveness (
/health) and readiness (/health/readywith DB check) - Structured logging — request ID middleware, fapilog, redacted sensitive headers
- Clean architecture — API → services → repositories → models; domain exceptions mapped in the API layer
- Production Docker — multi-stage (builder / development / runtime), non-root user, healthcheck
- Dev ergonomics — hot-reload, MailHog, compose overrides, dedicated test DB profile
- CI — Ruff, Mypy, full pytest suite + coverage (Codecov)
┌─────────────────────────────────────────┐
│ Clients / Tenants │
└───────────────────┬─────────────────────┘
│ X-API-Key
▼
┌───────────────────────────────────────────────────────────────────────────┐
│ FastAPI (app.main) │
│ Middleware: RequestID · Logging · Exception handlers │
│ Routes: /health · /api/v1/users · /devices · /notifications │
└───────────────┬─────────────────────────────┬─────────────────────────────┘
│ │
▼ ▼
┌───────────────┐ ┌───────────────┐
│ Services │ │ BackgroundTasks│
│ user/device │ │ send_notification
│ notification │ │ → SMTP / retry │
└───────┬───────┘ └───────────────┘
│
▼
┌───────────────┐ ┌─────────────┐
│ Repositories │────▶│ PostgreSQL │
└───────────────┘ └─────────────┘
│
▼
┌───────────────┐
│ Redis │ idempotency cache
└───────────────┘
| Layer | Responsibility |
|---|---|
app/api |
HTTP routes, deps (get_db, get_current_tenant), response envelope, exception → HTTP |
app/services |
Business rules, orchestration |
app/repositories |
Persistence queries |
app/infrastructure |
DB models/session, SMTP client, Redis |
app/core |
Settings, security, idempotency, domain exceptions |
app/schemas |
Pydantic request/response models |
Expand directory map
notify-service/
├── .github/workflows/ # CI: lint, type-check, tests + Codecov
├── alembic/ # Migrations
│ └── versions/
├── app/
│ ├── api/
│ │ ├── deps.py # DB session, tenant auth, idempotency, Redis
│ │ ├── exception_handlers.py
│ │ ├── responses.py
│ │ └── v1/
│ │ ├── router.py
│ │ └── endpoints/
│ │ ├── health.py
│ │ ├── users.py
│ │ ├── devices.py
│ │ └── notifications/
│ │ ├── common.py # list / get (all channels)
│ │ └── email.py # POST /notifications/email
│ ├── core/
│ │ ├── config.py
│ │ ├── security.py # API key generate / hash / verify
│ │ ├── idempotency.py
│ │ ├── exceptions.py
│ │ └── logging.py
│ ├── infrastructure/
│ │ ├── db/
│ │ │ ├── base.py
│ │ │ ├── session.py
│ │ │ └── models/ # Tenant, User, Device, Notification
│ │ └── clients/
│ │ └── email_client.py # aiosmtplib
│ ├── middleware/
│ │ └── request_id.py
│ ├── repositories/
│ ├── schemas/
│ ├── services/
│ │ ├── user_service.py
│ │ ├── device_service.py
│ │ ├── notification_service.py
│ │ ├── notification_email_service.py
│ │ ├── sender.py # background delivery + retries
│ │ └── tenant_service.py
│ └── main.py
├── tests/
│ ├── unit/
│ ├── integration/
│ └── conftest.py # async fixtures, TEST_DATABASE_URL
├── docker-compose.yml # base: Postgres + Redis
├── docker-compose.dev.yml # API hot-reload, MailHog, ports, db-test profile
├── docker-compose.prod.yml
├── Dockerfile # builder → development → runtime
├── pyproject.toml # deps, ruff, mypy, pytest, coverage
├── uv.lock
├── .env.example
└── README.md
- Docker & Docker Compose or
- Python 3.13+, uv, PostgreSQL 17, Redis 7
# 1. Env
cp .env.example .env.dev
# Edit .env.dev: POSTGRES_*, API_KEY_PEPPER (≥48 chars), SMTP_* for MailHog
# 2. Start stack
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build
# 3. Migrations
docker compose -f docker-compose.yml -f docker-compose.dev.yml exec api alembic upgrade head
# 4. API
open http://localhost:8000/docs| Service | URL |
|---|---|
| API / OpenAPI | http://localhost:8000/docs |
| MailHog UI | http://localhost:8025 |
| Postgres (host) | localhost:5432 |
| Redis (host) | localhost:6379 |
Stop everything:
docker compose -f docker-compose.yml -f docker-compose.dev.yml down -vuv sync --all-groups
cp .env.example .env
# Point POSTGRES_HOST / REDIS_URL at your local services
alembic upgrade head
uv run uvicorn app.main:app --reload --host 0.0.0.0 --port 8000Base path: /api/v1
Auth header: X-API-Key: sk_live_... (or sk_test_...)
Success envelope:
{
"success": true,
"data": { },
"meta": {
"timestamp": "2026-10-06T19:06:50.539390Z",
"request_id": "f0468f6b-cd9f-46bb-b87d-3cefdc1c0be6"
}
}| Method | Path | Description |
|---|---|---|
GET |
/health |
Liveness |
GET |
/health/ready |
Readiness (includes DB) |
| Method | Path | Description |
|---|---|---|
GET |
/api/v1/auth/me |
Return the tenant owning the API key |
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/users |
Create user (external_id unique per tenant) |
GET |
/api/v1/users/{external_id} |
Get user |
PATCH |
/api/v1/users/{external_id} |
Update email / soft-delete (is_active) |
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/users/{external_id}/device/register |
Register push device |
GET |
/api/v1/users/{external_id}/devices |
List active devices |
GET |
/api/v1/devices/{device_id} |
Get device |
PATCH |
/api/v1/devices/{device_id} |
Update name / deactivate |
| Method | Path | Description |
|---|---|---|
POST |
/api/v1/notifications/email |
Create email notification (202) |
GET |
/api/v1/notifications/ |
List (filters: status, channel, pagination) |
GET |
/api/v1/notifications/{id} |
Get by ID |
Optional header on email create:
Idempotency-Key: <client-uuid>Retries with the same key return the original 202 response body (Redis-backed).
curl -s -X POST http://localhost:8000/api/v1/notifications/email \
-H "Content-Type: application/json" \
-H "X-API-Key: sk_test_YOUR_KEY" \
-H "Idempotency-Key: $(uuidgen)" \
-d '{
"user_id": "user-1",
"subject": "Welcome!",
"body": "Thanks for signing up.",
"from_email": "noreply@example.com"
}'With MailHog in dev (SMTP_HOST=mailhog, SMTP_PORT=1025, SMTP_USE_TLS=false, SMTP_ENABLED=true), open http://localhost:8025 to inspect the message.
# Local (uv / venv) — requires DB env vars loaded
python -m app.scripts.create_tenant "New Tenant"
# Optional: test-environment key prefix (sk_test_...)
python -m app.scripts.create_tenant "New Tenant" --env test
# Inside Docker API container
docker compose -f docker-compose.yml -f docker-compose.dev.yml \
exec api python -m app.scripts.create_tenant "New Tenant"The command prints the tenant id, key prefix, and the full API key once. Store it securely; it is not shown again.
| Entity | Notes |
|---|---|
| Tenant | API key owner; api_key_hash + api_key_prefix; soft-delete |
| User | (tenant_id, external_id) unique; optional email; soft-delete |
| Device | Push token (unique), platform, provider (FCM), soft-delete |
| Notification | Channel enum, status (pending / delivered / failed), attempts, payload JSONB; not soft-deleted (audit trail) |
Delivery (email): background task loads notification → SMTP via aiosmtplib → up to 3 attempts with backoff on transient errors; permanent errors mark failed.
Copy .env.example. Important variables:
| Variable | Purpose |
|---|---|
POSTGRES_USER / PASSWORD / DB / HOST / PORT |
Database (in Docker use host db) |
API_KEY_PEPPER |
Secret ≥ 48 chars for key hashing |
REDIS_URL |
e.g. redis://redis:6379/0 in Docker |
SMTP_ENABLED |
true to send for real (else mock log) |
SMTP_HOST / PORT / USE_TLS |
MailHog: mailhog, 1025, false |
IDEMPOTENCY_TTL_SECONDS |
Default 86400 |
Inside Docker Compose, service hostnames are db, redis, mailhog — not localhost.
- builder — uv sync production deps into
/opt/venv - development — builder +
--devdeps (for composetarget: development) - runtime — slim image, non-root
appuser(UID 1001), healthcheck on/health
Production image only copies the venv from builder (no dev packages).
# Development
docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d
# Production-style
docker compose -f docker-compose.yml -f docker-compose.prod.yml up -dTest database (profile test, port 5433 — matches tests/conftest.py):
docker compose -f docker-compose.yml -f docker-compose.dev.yml --profile test up -d db-testAlways use the same -f pair for down as for up.
# Install (incl. dev tools)
uv sync --all-groups
# Lint & format
uv run ruff check .
uv run ruff format .
# Types
uv run mypy app/
# Pre-commit (optional)
pre-commit installMigrations:
alembic revision --autogenerate -m "describe change"
alembic upgrade head# Needs Postgres on 5433 (db-test) or override TEST_DATABASE_URL
export TEST_DATABASE_URL=postgresql+asyncpg://user:pass@localhost:5433/notif_test
export API_KEY_PEPPER=test-pepper-with-at-least-48-characters-for-ci-only-xyz
export POSTGRES_USER=user POSTGRES_PASSWORD=pass POSTGRES_DB=notif_test
uv run pytest tests/ -v --cov=app --cov-report=term-missing- Unit (
tests/unit) — no real DB; mocks - Integration (
tests/integration) — real Postgres; Redis via fakeredis - Background email send is mocked in the HTTP client fixture to avoid event-loop issues
| Workflow | Role |
|---|---|
lint.yml |
Ruff check + format |
type-check.yml |
Mypy |
tests.yml |
Full pytest + coverage → Codecov |
Path filters can skip docs-only changes via paths-ignore (e.g. **.md) on each workflow.
- API keys never stored in plaintext; pepper stays only on the server
- Failed auth always returns the same generic message (no oracle)
- Soft-delete preferred over hard-delete for tenants/users/devices
- Notifications retained for audit; FKs use
RESTRICT - Sensitive headers redacted in access logs
- Push delivery (FCM) using registered devices
- SMS channel
- Rate limiting
- Outbox / queue worker instead of in-process
BackgroundTasksfor multi-replica - OpenAPI examples & public changelog
MIT — see LICENSE.
OpenAPI docs when running locally · Built with FastAPI & uv