Multi-tenant notifications platform
A notifications product with a backend SDK for sending events and a frontend SDK for reading them, built to make in-app notifications simple to add and reliable to run.

Most teams start notifications as a database table and a frontend badge. Pigeon treats them as a cross-cutting product surface from the first commit.
Backends should call one client to send. Frontends should mount a provider and render a bell that stays live, survives reconnects, and reads optimistically. Customers should receive signed webhooks with retries that leave evidence. None of that should be assembled from scratch inside the consuming app.
The result is a TypeScript monorepo: an API, a worker, a web dashboard, a Node SDK, a React SDK, and a demo that exercises the full loop end-to-end.
The platform's job is to disappear behind two SDKs. A backend sends with one call and mints a client token with another. A frontend mounts a provider and drops in a bell. Validation and typed errors live at the boundary, not inside the consuming app.
import { Pigeon } from "@flypigeon/node"; const pigeon = new Pigeon({ apiKey: process.env.PIGEON_API_KEY! }); // Send a notification from your backendawait pigeon.send({ userId: "user_123", type: "comment.reply", title: "New reply", body: "Alex replied to your comment", data: { threadId: "t_42" },}); // Mint a short-lived token for the browserconst { token, expiresAt } = await pigeon.createUserToken({ userId: "user_123", ttlSeconds: 3600,});import { PigeonProvider, NotificationBell } from "@flypigeon/react"; // Drop-in inbox: a provider and a bellexport function App() { return ( <PigeonProvider apiUrl={API_URL} tokenProvider={fetchToken}> <NotificationBell panelTitle="Inbox" pageSize={20} /> </PigeonProvider> );}import { useNotifications } from "@flypigeon/react"; // Or build a custom UI on the hookfunction Inbox() { const { notifications, unreadCount, markRead, markAllRead } = useNotifications();}The Node SDK sends. The API persists the request, hands work to background processing, and returns. Workers render, publish live updates, and dispatch signed webhooks. The React SDK opens an SSE stream and resumes cleanly after reconnects. Underneath, one Redis instance does four jobs — queue, pub/sub, replay log, and rate limiter — leaving Postgres as the single source of truth.
Accept fast, deliver async, fan out everywhere. The send returns after durable persistence and job handoff. The slower work stays out of the caller's request path.
Projects are top-level. Each project has development and production environments with their own scoped credentials. Almost everything that matters, including keys, users, notifications, templates, and webhooks, lives behind an environment, not just a project.
Backends authenticate with long-lived API keys — hashed at rest and matched by prefix. Frontends use short-lived JWTs, minted per end user and signed with a per-environment secret. The budgets differ too: a Redis sliding window caps key-based writes at 100 req/s and token-based reads at 1000 req/s, answering 429 with retry-after. Different blast radius, different limits, one API enforcing both.
The schema is organized around a simple rule: human collaboration lives at the project level, while credentials, recipients, notifications, templates, and webhooks are scoped per environment. That keeps tenancy visible in the data model instead of depending on scattered application checks.
Better Auth — dashboard sign-in, not end users
workspace and membership boundaries
credential storage and recipient identity
delivery records, content, and idempotency
delivery destinations and attempt history
// notifications table (Drizzle)uniqueIndex("notifications_environment_idempotency_unique") .on(table.environmentId, table.idempotencyKey) .where(sql`${table.idempotencyKey} IS NOT NULL`);A partial unique index makes duplicate sends impossible per environment — the guarantee lives in Postgres, not in application code that every caller has to remember.
Live fanout and reconnect replay are handled separately. Connected clients receive updates immediately, while reconnecting clients get a short recovery window so brief network drops do not turn into missed notifications.
Duplicate sends are blocked at the persistence layer, not just discouraged in application code.
Transient coordination stays separate from the system of record, which keeps recovery and reasoning much simpler.
The notification commits before background processing begins, so failures surface clearly instead of disappearing into side effects.
Webhook delivery leaves a traceable attempt history, which matters for debugging and support.
The product needs one-way delivery, not full duplex messaging. SSE keeps the auth and reconnect story simpler for this scope.
Workers may retry a job after partial success. Idempotency keys carry the weight at the boundaries.
Reconnect history exists to smooth over short disconnects, not to act as a permanent event ledger.
No multi-region or active-active deployment story yet. The MVP picks scope over posture.
Rendering, fanout, and logging cannot sit on the caller's request. The send has to return fast or every integration feels the slowest dependency.
Live fanout does not retain history. Reconnects need a cursor and a replay window the SDK can drive without consumer code.
Endpoints time out, 5xx, or 200-then-crash. Delivery has to retry, and every attempt has to leave evidence.
Backends authenticate with long-lived keys, frontends with short-lived tokens. Different blast radius, different rate budgets, one API.
In-app + webhooks today. No email, SMS, or push.
No multi-region, failover, or disaster-recovery story shipped.
Basic dashboard authentication only. No SSO or enterprise identity layer yet.
One Node SDK call sends a notification, another mints a frontend token. Validation and typed errors live at the boundary, not inside the app.
A provider, a hook, and a bell. Token caching, reconnects, and optimistic reads are handled inside the SDK, not in consumer code.
Live events stream into the SDK while reconnects resume from the last seen event. Backgrounded tabs and brief network loss stop dropping notifications.
A platform is only as good as the libraries integrators actually touch. Two clean SDKs do more for adoption than another feature behind a flag.
Live delivery and replay want different storage. Solve them separately and they compose.
Project and environment scoping in indexes and uniqueness constraints made the rest of the system easier to reason about than enforcing it in code paths.
Slow work goes to a queue, fast work stays inline. That line is the architecture.
Long-form to short-form, automated