Multi-tenant notifications platform
A notifications product with a backend SDK for sending events and a frontend SDK for reading them. The API accepts and persists, workers render and fan out, and the React SDK keeps an inbox live across reconnects.

Most teams start notifications as a database table and a frontend badge. Pigeon treats them as a product of their own 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. Nobody should have to assemble that from scratch inside their own 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. Postgres holds everything durable.
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 follows one 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 for 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 run on separate paths. Connected clients receive updates immediately, while reconnecting clients get a short recovery window so brief network drops do not turn into missed notifications.
The persistence layer blocks duplicate sends. No caller has to remember to check first.
Transient coordination stays out of the system of record, so losing Redis costs a replay window and nothing else.
The notification commits before background processing begins, so failures surface clearly instead of disappearing into side effects.
Every attempt writes a row with its status code and response, so support can answer "did it fire?" without a log search.
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, so a backgrounded tab or a brief network drop doesn't turn into a lost notification.
A platform is only as good as the libraries integrators actually touch. Two clean SDKs remove more friction than another feature behind a flag.
Live delivery and replay want different storage. Solve them separately and they compose.
Project and environment scoping lives in the indexes and uniqueness constraints, so a query that forgets its scope fails at the database instead of quietly returning another tenant's rows.
Slow work goes to a queue, fast work stays inline. That line is the architecture.
Long-form to short-form, automated