A multi-tenant backend API that enforces per-tenant monthly usage limits using API-key authentication.
The system models a common SaaS backend problem: isolating tenants, tracking usage reliably, and enforcing quotas under concurrent access, without relying on background workers.
- Multi-tenant architecture with strict tenant isolation
- API-key authentication via
X-API-Keyheader - Admin vs tenant authorization separation
- Per-tenant monthly usage limits
- Concurrency-safe usage metering using transactional row-level locking
- Lazy billing-period rollover without any background jobs
- Alembic migrations
- Each tenant is represented by a
Tenantrecord containing plan configuration, billing period, and usage. - Every request is scoped to exactly one tenant, resolved via an API key.
- Usage accounting and authorization decisions are isolated per tenant via
tenant_id.
This design supports an arbitrary number of tenants while keeping usage tracking and quota enforcement strictly separated.
- Clients authenticate using an API key provided via the
X-API-Keyheader. - API keys are generated once during tenant creation.
- Only a hashed version of the API key is stored in the database.
- The plaintext key is never retrievable after creation.
- Admin-only endpoints are protected using a static admin token loaded from application configuration.
- The admin token is not hardcoded and can be replaced via environment configuration changes.
- Admin-only endpoints: tenant lifecycle and usage control
- Tenant-only endpoints: usage consumption and usage inspection
Each tenant maintains:
monthly_usage_limitcurrent_usageperiod_start(inclusive)period_end(exclusive)
Billing periods align with calendar months and are stored explicitly per tenant.
The system does not rely on background workers or scheduled jobs to reset usage.
Instead, billing rollover is handled lazily:
-
On each usage-increment request, the current time is compared against
period_end. -
If the billing period has elapsed:
- Usage is reset to zero.
- New
period_startandperiod_endvalues are calculated for the new month.
This approach ensures:
- No cron jobs or background workers are required
- Correct behavior even if a tenant is inactive for extended periods
- Deterministic state transitions driven by real traffic
Usage increments are handled inside a single database transaction using row-level locking:
- The tenant row is selected using
SELECT ... FOR UPDATE. - Billing rollover (if required) and quota checks are performed while holding the lock.
- Usage is incremented atomically and committed.
This guarantees:
- No race conditions under concurrent requests
- No quota overruns due to parallel increments
- At-most-once usage increment per request
- Creates a new tenant with a plan and monthly usage limit
- Initializes the billing period
- Generates and returns the tenant API key once
- Updates tenant plan and monthly usage limit
- Does not rotate or reissue API keys
- Resets
current_usageto zero - Recalculates the billing period based on the current month
- Intended for administrative or billing correction scenarios
- Increments tenant usage by one unit per request
- Applies lazy billing rollover if the billing period has elapsed
- Enforces monthly usage limits atomically
- Returns current usage and billing-period information for the tenant
- Automated billing-cycle resets via background workers
- API key rotation and revocation
- Rate limiting per tenant
- Integration with external billing providers (like Stripe)
This project was built to demonstrate:
- Multi-tenant backend design
- Correct quota enforcement under concurrency
- Clean separation of authentication and authorization concerns
- Practical backend tradeoffs for usage-metered APIs
It is intended as a learning and portfolio project rather than a production SaaS system.
Make sure docker and make is installed
- Clone the repo and change dir
git clone git@github.com:Infamous003/multi-tenant-backend.git
cd multi-tenant-backend- Start the app
make startThis will start the postgres db, do migrations, and start the api
- Server should be running at:
http://127.0.0.1:8000/Swagger docs:
http://127.0.0.1:8000/docs