Multi-tenant SaaS with operator oversight and billing that does not trust the browser
Scopelyt shaped a commercial SaaS shell for founders who need more than one organisation on one codebase: a tenant workspace, a separate operator console, and a NestJS API where paid state follows Stripe webhooks, not checkout redirects. Live tenant and operator apps plus a public OpenAPI surface (68 paths) let you inspect the spine before feature creep.
- Industry
- B2B SaaS
- Type
- Multi-tenant platform
- Billing
- Stripe webhooks
- Stack
- NestJS · React · MongoDB
01 — Demos
- Tenant workspace
- Operator console
- API reference
Swagger on a free-tier host; first request may take a moment to wake.
02 — Problem
A small team still needs the shape of commercial SaaS, not a single-tenant prototype with org ids pasted into forms. Onboarding a second customer should not mean forking the repo or trusting the browser to say whether a plan is paid. Operators need a desk to see subscription state across tenants, reset MFA lockouts, and manage plan catalogues without opening twelve Stripe dashboards. Tenant admins need invites, org profiles, and billing self-service that stays honest when someone closes the checkout tab early. One API has to serve both surfaces with different guards, not two backends that drift apart.
03 — Constraints
- Tenant identity must come from the signed session, never from a client-supplied organisation id. Login for tenant scope requires tenantRole (admin or member); platform operators omit it.
- Paid state follows Stripe. The UI, checkout return URL, and checkout-success sync helper are not the source of truth. Signed webhooks at POST /stripe/webhook update the subscription snapshot in MongoDB.
- Platform operators and tenant users share one NestJS API with route-level guards, not two backends. Cross-tenant reads and writes exist only under /api/platform/*; tenant modules stay org-scoped.
04 — Responsibilities
- Built end-to-end as sole engineer: tenant React workspace, separate operator console, and modular NestJS API documented in public Swagger.
- Tenant identity from the signed session. Org-scoped queries carry the tenant key from auth; only operator routes may list, patch, or soft-delete tenants across organisations.
- Auth: public registration creates tenant and first user server-side (operators never created through that path), email verification before session, access and refresh tokens with single-use rotation, logout, optional TOTP with backup codes, and email-code login.
- Billing: public plan catalogue, Stripe Checkout and Customer Portal sessions, cancellation (immediate or period end), refunds against latest paid invoice, and webhook-driven subscription sync. Operator plan create writes Stripe Product, Price, and Mongo record in one flow.
- Tenant user invites with accept-invite flow. Platform admin: cross-tenant subscription rows, MRR/ARR analytics, plan catalogue versioning, user search with MFA reset. In-app notifications and transactional email follow domain events in-process. Plan-gated tenant chat checks GET /api/chat/eligibility before any session.
05 — Architecture
- One NestJS modular monolith (12 OpenAPI tags, 68 paths), MongoDB persistence, a tenant React app, and a separate operator console React app.
- Org-scoped tenant modules (Tenant, Tenant Users, Billing, Chatbot, Notification) take the tenant key from the authenticated session. Cross-tenant reads exist only on /api/platform/* operator routes.
- Access and refresh tokens with rotation and logout. Optional TOTP MFA with backup codes. Email verification gates first session after registration.
- Stripe checkout, customer portal, and signed webhooks update subscription state. Notifications and mail follow domain events in-process; no message broker in this deployment.
- Tenant chat gated by plan entitlements (features.aiChatbot on subscription plans). Eligibility checked on the server before session create. Workspace and File API modules exist as structure only and are not claimed as shipped product.
06 — Technical decisions
- Shared MongoDB with a tenant field on org-scoped documents, not a database per organisation. Operator analytics, cross-tenant tenant lists, and subscription rows stay one query path instead of N database connections.
- Webhooks at POST /stripe/webhook decide whether a subscription is active. Checkout return and checkout-success sync can help the UI catch up, but paid state does not trust the browser alone.
- Domain events stay in-process so notifications and transactional email do not require a queue at current scale. Notification module handles in-app inbox (org-wide for tenant admins, personal for members) and mail from the same API process.
07 — Implementation
- Public registration accepts organizationName, displayName, email, and password (min 8). Server creates the tenant and first user, sends verification email, and blocks login until verify-email completes. Operators are provisioned separately, not through registration.
- API documented publicly in OpenAPI 3. Live tenant workspace and operator console linked as demos. Operator subscription-plan create syncs Stripe Product, Price, and Mongo in one admin flow; archived plans return 404 on the public catalogue but remain for webhook history.
08 — Difficult problems
The hard line is tenancy. Every tenant-user handler must stay inside one organisation: org-scoped queries always take the tenant key from the authenticated session, never from an optional filter parameter or a request body field. The operator console is allowed to cross that line, but only on /api/platform/* routes with platform-scoped guards. The failure mode is a shared handler that optionally skips the tenant filter: one missing branch and tenant A reads tenant B data. Route prefix separation (tenant modules vs /api/platform/*) enforces the boundary so the two paths cannot quietly merge.
09 — Trade-offs
- In-process domain events keep operations simple and avoid a broker at current scale. They will not survive multiple API instances without a later move to a queue; that trade is accepted for a single-node deployment on Render.
- Workspace task and File (Cloudinary signed upload) modules exist in the API and OpenAPI as structure for a future product surface. They are intentionally excluded from portfolio claims so the case study stays honest about what is shipped versus scaffolded.
10 — Result
Live tenant workspace and operator console demos plus a public OpenAPI surface you can read without an account. A founder can verify session-scoped tenancy, operator-only cross-tenant routes, billing modules, and chat eligibility endpoints in Swagger before asking Scopelyt to extend the shell. Scopelyt can show the multi-tenant spine (auth, isolation, webhook billing, plan-gated features) without inventing adoption metrics. Workspace and file storage remain off the public story until they ship as product, not API structure.
11 — System map
Multi-tenant SaaS Platform
Multi-tenant platformVerifiedHover or focus a node to read its role in the system.
Derived from the running tenant app, operator console, and NestJS API. Workspace task and file-storage modules omitted because they are structure only.
12 — Stack
2
3
4
5
> |
// Client builds get the same clarity-before-code bias. Lock scope, then execute.
// Same pod. Weekly demos. You own what ships.
