Feature entitlement API for SaaS. Plan-based access control, usage tracking, and billing infrastructure — without the complexity.
SaaS companies routinely waste engineering cycles hardcoding plan limits and rebuilding billing logic every time pricing changes. Tenon decouples monetization from core business logic. Your payment platform handles the money. Tenon handles what each customer is allowed to do.
Integrate once and get usage tracking, rate limiting, and feature gating out of the box — works with whatever payment provider you already use. Built for SaaS products that need plan-based access control without rebuilding it from scratch.
This repository covers the entire backend API. A separate Next.js frontend handles the Tenon dashboard.
| Layer | Technology |
|---|---|
| Runtime | Node.js + TypeScript |
| Framework | Express 5 |
| Database | PostgreSQL |
| ORM | Drizzle ORM |
| Auth | JWT (access + refresh) + token versioning |
| Payments | Stripe |
| Nodemailer | |
| Security | Helmet, bcrypt |
- Node.js
>= 18 - PostgreSQL database
- Stripe account with webhook signing secret
- A
.envfile with the variables listed below
git clone https://github.com/OgheneDev/billing-engine.git
cd billing-engine
npm install# Server
PORT=3000
NODE_ENV=development
# Database
DATABASE_URL=postgresql://user:password@localhost:5432/tenon
# Auth
JWT_SECRET=
JWT_REFRESH_SECRET=
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
# Stripe
STRIPE_SECRET_KEY=
STRIPE_WEBHOOK_SECRET=
# Email (Nodemailer)
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
FROM_EMAIL=no-reply@yourdomain.com
# App
FRONTEND_URL=http://localhost:3001# Push schema to database
npm run db:push
# Or generate and run migrations
npm run db:generate# Development (with watch)
npm run dev
# Production
npm run build
npm startAll tables use UUID primary keys. The schema is defined in src/db/schema.ts using Drizzle ORM.
Tenon platform users — the people who log into the Tenon dashboard to manage their org.
| Column | Type | Notes |
|---|---|---|
id |
uuid | PK |
email |
varchar(255) | Unique, indexed |
password_hash |
varchar(255) | bcrypt hashed |
name |
varchar(255) | |
email_verified |
boolean | |
token_version |
integer | Incremented on logout/password change to invalidate all sessions |
auth_provider |
varchar(50) | Default password |
reset_password_token |
text | |
reset_password_expires |
timestamp | |
last_login_at |
timestamp | |
last_password_change |
timestamp |
The top-level tenancy unit. Represents a Tenon customer — a SaaS company using the API.
| Column | Type | Notes |
|---|---|---|
id |
uuid | PK |
name |
varchar(255) | |
slug |
varchar(100) | Unique, indexed |
owner_user_id |
uuid | FK → users |
billing_email |
varchar(255) | |
stripe_customer_id |
varchar(255) | Unique |
max_seats |
integer | Default 5 |
deleted_at |
timestamp | Soft delete |
Junction table placing users inside orgs with a role. Tracks invitation lifecycle.
| Column | Type | Notes |
|---|---|---|
organization_id |
uuid | FK → organizations |
user_id |
uuid | FK → users |
role |
varchar(50) | owner | admin | member |
invited_by |
uuid | FK → users |
status |
varchar(30) | invited | active |
joined_at |
timestamp |
Indexed on (organization_id, user_id) — unique pair.
Platform-level plan definitions. These are Tenon's own billing plans (what orgs pay to use Tenon).
| Column | Type | Notes |
|---|---|---|
id |
uuid | PK |
key |
varchar(100) | Unique — used as the stable plan identifier |
name |
varchar(255) | |
billing_interval |
varchar(20) | month | year |
base_price |
integer | In cents |
currency |
varchar(3) | Default usd |
is_metered |
boolean |
Feature limits attached to each plan. Stored as (plan_key, feature_key, limit) rows — adding a feature to a plan is a data operation, not a code change.
| Column | Type | Notes |
|---|---|---|
plan_key |
varchar(100) | FK → plans.key |
feature_key |
varchar(100) | e.g. api_calls, exports, seats |
limit |
integer | null = unlimited |
Unique on (plan_key, feature_key).
Stripe subscription state mirrored locally. Fully driven by Stripe webhooks — never polled.
| Column | Type | Notes |
|---|---|---|
organization_id |
uuid | FK → organizations |
stripe_subscription_id |
varchar(255) | Unique |
stripe_price_id |
varchar(255) | |
stripe_product_id |
varchar(255) | |
status |
varchar(50) | Mirrors Stripe status |
plan_key |
varchar(100) | |
plan_snapshot |
text | JSON — plan state at time of subscription |
seat_limit |
integer | |
current_period_start |
timestamp | |
current_period_end |
timestamp | |
cancel_at_period_end |
boolean | |
grace_period_ends_at |
timestamp | |
trial_start / trial_end |
timestamp |
Stripe invoice records mirrored locally. Written on invoice.paid webhook events.
| Column | Type | Notes |
|---|---|---|
organization_id |
uuid | FK → organizations |
subscription_id |
uuid | FK → subscriptions |
customer_id |
uuid | FK → customers |
stripe_invoice_id |
varchar(255) | Unique |
amount_due |
integer | In cents |
amount_paid |
integer | |
tax |
integer | |
status |
varchar(50) | |
hosted_invoice_url |
varchar(500) | |
invoice_pdf |
varchar(500) | |
paid_at |
timestamp |
Individual payment records linked to invoices.
| Column | Type | Notes |
|---|---|---|
invoice_id |
uuid | FK → invoices |
provider |
varchar(50) | e.g. stripe |
provider_payment_id |
varchar(255) | |
status |
varchar(50) | |
amount |
integer | In cents |
currency |
varchar(3) |
Org-defined plans for their end-customers. This is the core Tenon product — orgs create plans here that gate access in their own SaaS.
| Column | Type | Notes |
|---|---|---|
organization_id |
uuid | FK → organizations |
key |
varchar(100) | Stable plan identifier |
name |
varchar(255) | |
features |
text | JSON — { "api_calls": 1000, "exports": 10, "advanced": null }. null = unlimited, absent key = blocked |
is_active |
boolean |
Unique on (organization_id, key).
End-customers of the org — the users of the org's own SaaS product. Tenon never needs to be their system of record.
| Column | Type | Notes |
|---|---|---|
organization_id |
uuid | FK → organizations |
external_id |
varchar(255) | The org's own ID for this user. Unique per org — Tenon stores no PII it doesn't need |
email |
varchar(255) | Optional |
plan_key |
varchar(100) | FK → customer_plans.key |
status |
varchar(50) | Default active |
metadata |
text | Arbitrary JSON the org wants to store |
current_period_start |
timestamp | Resets on plan change |
Unique on (organization_id, external_id).
Immutable event log. Written every time a customer performs a metered action. Idempotent by (organization_id, idempotency_key).
| Column | Type | Notes |
|---|---|---|
organization_id |
uuid | FK → organizations |
subscription_id |
uuid | FK → subscriptions |
customer_id |
uuid | FK → customers |
event_type |
varchar(100) | e.g. api_call, export |
quantity |
integer | Default 1 |
idempotency_key |
varchar(255) | Prevents double-counts |
reported_to_stripe |
boolean | Async Stripe metered billing sync |
stripe_usage_record_id |
varchar(255) | |
metadata |
text |
Indexed on (organization_id), (timestamp), and (organization_id, event_type).
Pre-aggregated usage totals per org per billing period per metric. Written by a background job — avoids full usage_events scans on every entitlement check.
| Column | Type | Notes |
|---|---|---|
organization_id |
uuid | FK → organizations |
metric |
varchar(100) | |
period_start |
timestamp | |
period_end |
timestamp | |
total |
integer |
Unique on (organization_id, metric, period_start, period_end).
Threshold alerts that fire when a customer's usage for a feature reaches a set percentage of their plan limit.
| Column | Type | Notes |
|---|---|---|
organization_id |
uuid | FK → organizations |
customer_id |
uuid | FK → customers |
feature |
varchar(100) | |
threshold |
integer | 0–100. Fires when usage % hits this |
fired |
boolean | |
fired_at |
timestamp |
Unique on (customer_id, feature).
Idempotency and retry log for incoming Stripe webhooks. Every event is recorded before processing — duplicate delivery is safe.
| Column | Type | Notes |
|---|---|---|
event_id |
varchar(255) | Unique — Stripe event ID |
event_type |
varchar(255) | |
processed |
boolean | |
processing_error |
text | |
attempts |
integer | |
next_retry_at |
timestamp | |
idempotency_key |
varchar(255) | |
provider |
varchar(50) | Default stripe |
data |
text | Raw event payload |
processed_at |
timestamp |
API keys issued to orgs for authenticating requests to the public V1 API. Stored as SHA-256 hashes — the raw key is shown once at creation.
| Column | Type | Notes |
|---|---|---|
organization_id |
uuid | FK → organizations |
hashed_key |
varchar(64) | SHA-256, never stored in plaintext |
last_used_at |
timestamp | |
revoked_at |
timestamp | Null = active |
Security telemetry for brute-force detection. Indexed on email, user ID, and timestamp for fast windowed queries.
| Column | Type | Notes |
|---|---|---|
user_id |
uuid | FK → users (nullable — tracks attempts on unknown emails too) |
email |
varchar(255) | |
ip_address |
varchar(45) | IPv6-safe |
reason |
varchar(100) |
Generated internally when anomalous patterns are detected (brute force, multi-IP auth, etc.). Resolvable by org admins.
| Column | Type | Notes |
|---|---|---|
organization_id |
uuid | FK → organizations |
user_id |
uuid | FK → users |
type |
varchar(100) | |
severity |
varchar(20) | |
title |
varchar(255) | |
description |
text | |
metadata |
text | |
resolved |
boolean | |
resolved_by |
uuid | FK → users |
audit_log_ids |
uuid[] | Array of linked audit log entries |
Security event log. Non-blocking async writes — never in the critical path.
| Column | Type | Notes |
|---|---|---|
organization_id |
uuid | FK → organizations |
user_id |
uuid | FK → users |
action |
varchar(100) | e.g. plan.created, customer.plan_changed |
resource_type |
varchar(50) | |
resource_id |
uuid | |
severity |
varchar(20) | Default info |
ip_address |
varchar(45) | |
details |
text |
Indexed on (organization_id), (timestamp), and (action).
Tenon has a deliberate two-layer model that's worth understanding upfront:
- Orgs — companies that pay Tenon to use the platform. They log into the dashboard, create plans, and call the API.
- Customers — the end-users of those orgs' own SaaS products. Tenon never requires their PII; they're referenced by
external_id— whatever ID the org already uses in their own system.
This separation means an org can integrate Tenon without migrating their user table or exposing user data to a third party.
JWT access tokens (short-lived) + refresh tokens (long-lived, HTTP-only cookies). token_version on users is incremented on logout and password change, atomically invalidating all active sessions without maintaining a token blocklist.
Public V1 API keys use a tn_ prefix and are stored as SHA-256 hashes — the pattern mirrors Stripe's sk_ model. The plaintext key is shown exactly once at creation. Revocation is a single revoked_at timestamp write.
Two route namespaces with different stability guarantees:
/api/*— internal dashboard API. Can evolve freely alongside the frontend./api/v1/*— public integration API consumed by orgs' own backends. Versioned for stability.
Feature limits live in plan_features rows, not code. Checking entitlement at request time reads from the org's active plan — adding or modifying limits requires no redeployment. The customer_plans.features JSON column uses a deliberate encoding: null means unlimited, an absent key means the feature is blocked entirely.
Subscription state is fully driven by Stripe webhooks — no polling. Every incoming event is written to webhook_events before processing, making duplicate delivery idempotent. The processed flag and attempts counter support retry logic for transient failures.
Usage events are written to usage_events with an idempotency_key — a unique constraint on (organization_id, idempotency_key) prevents double-counts at the database level regardless of retry behavior. Events are tracked locally before async reporting to Stripe for metered billing, decoupling the critical path from Stripe latency.
usage_aggregates stores pre-rolled totals per billing period so entitlement checks read a single row rather than summing the full event log.
Brute-force and multi-IP anomaly detection runs against failed_login_attempts without any external SIEM dependency. Detected patterns generate security_alerts that org admins can investigate and resolve. All security-relevant actions write to audit_logs asynchronously — fire-and-forget, never blocking the response.
/auth Registration, login, refresh, logout, password reset, email verification
/users Profile management
/orgs Org creation, member management, seat management, settings
/plans Platform plan listing
/subscriptions Stripe subscription management, portal, status
/api-keys Key creation, listing, revocation
/customers End-customer CRUD, plan assignment
/customer-plans Org-defined plan creation and management
/usage Event ingestion, aggregation queries, threshold alerts
/entitlements Feature gate checks — the core integration endpoint
/webhooks Stripe webhook receiver
/invoices Invoice listing and detail
/security Alert listing, resolution, audit log access
- Role: Solo — full backend and frontend.
- Time invested: ~39h backend, ~17h frontend.
- Status: Live at tenon-org.vercel.app.
Emmanuel Oghene — Fullstack Developer, Lagos Portfolio · GitHub · LinkedIn