Yasser Ameur

05 / 06NotiFlyPython · FastAPI · Postgres

How NotiFly unifies 6 delivery channels behind one 24-endpoint API

A channel-agnostic notification orchestration platform with a transactional outbox for guaranteed delivery

delivery channels behind one API
6
Source: src/notifly/infrastructure/providers/, 2026-09-04

The problem

Every product I build ends up needing to notify someone: an email, a Slack message, a webhook into another service. The usual answer is to wire each channel's SDK into the product directly, which means every product re-solves retries, templating and delivery tracking on its own, and a crashed worker can quietly drop a message with no record it ever existed. I wanted a platform that sits between a product and its channels, where the product calls one API and the platform is the one place that has to get delivery right.

The mechanism

NotiFly is laid out as strict clean architecture: presentation, application, domain, infrastructure, with dependencies pointing inward only, so a FastAPI router never contains business logic and the domain layer never imports SQLAlchemy. A request to send a notification goes through presentation/api into application/services/notifications.py, the Notification Engine, which validates variables, renders the per-channel template and writes everything in one database transaction: the notification row, its per-channel deliveries, an idempotency record, an audit log entry and a pending row in outbox_events. That last write is the transactional outbox. Nothing is sent from inside that transaction. A separate relay, application/services/outbox.py, polls the pending outbox rows on an ARQ cron job and enqueues the actual dispatch. If Redis or the worker process is down, the outbox rows just stay pending in Postgres until the relay comes back, so the notification is never lost between "the database says it happened" and "a channel actually sent it". The dispatcher, application/services/dispatcher.py, is the only piece that touches a provider, and it only ever calls the shared Provider interface defined in domain/ports/providers.py, never a channel directly. The six adapters that satisfy that interface live in src/notifly/infrastructure/providers/: email.py, slack.py, discord.py, teams.py, webhook.py and http.py. Adding a seventh channel means writing one adapter file and registering it; the engine, the outbox and the dispatcher stay untouched.

The request path from the REST API to a provider, with the transactional outbox stage highlighted. Module names read from src/notifly.

What it did

The platform exposes 24 REST endpoints, from /v1/apps and /v1/templates to the operations routes for inspecting notifications, deliveries and dead letters, and it ships behind those endpoints with six working provider adapters: SMTP email, Slack, Discord, Microsoft Teams, generic webhooks and a plain HTTP provider. Both numbers are structural counts, not a measured throughput or a reliability figure: I did not run a load test against this API, and there is no committed benchmark in the repo. What the outbox buys is a specific, checkable property, that a notification cannot be acknowledged as created and then silently disappear if the worker crashes between enqueuing and sending, because the outbox row stays pending in Postgres until the relay picks it up. That is a design guarantee I can point to in the code, not a number I measured under load.

What I learned

Splitting the Notification Engine from the Dispatcher, so that rendering and persistence happen in one service and only transport happens in the other, turned out to matter more than I expected: it means the worker that talks to Slack or SMTP never needs a template engine or write access to the notification's business state, which makes it small enough to reason about and safe to restart at any point. The other thing that stuck with me is that a provider is genuinely just an adapter behind a capabilities interface, not a feature with its own logic scattered through the app. Every channel-specific quirk, like Slack's block format or SMTP's message headers, stays inside its one file under infrastructure/providers/, and the dispatcher never has to know which channel it is talking to.

Sources

  1. Brand/Portfolio/content/projects/NUMBERS.md, NotiFly section, date 2026-09-04
  2. Personal/Main/NotiFly/README.md, date 2026-09-04
  3. Personal/Main/NotiFly/docs/architecture.md, date 2026-09-04
  4. `ls src/notifly/infrastructure/providers/` (repo), 6 files excluding __init__.py, date 2026-09-04
  5. `grep -rE "@router\.(get|post|put|delete|patch)" --include=*.py .` (repo), 24 matches, date 2026-09-04