Saasly developer docs
Everything you need to run, integrate with, and customize this SaaS foundation — auth, multi-tenancy, the public REST API, webhooks, and billing.
This starter is a multi-tenant SaaS foundation built on Next.js 16, Drizzle ORM over SQLite, and Stripe. The plumbing every product needs is already wired together so you can focus on your actual features.
- Authentication — JWT sessions in httpOnly cookies, email verification, and password reset.
- Two-factor auth — TOTP via authenticator apps with QR setup and one-time recovery codes.
- Multi-tenancy — organizations with
owner,admin, andmemberroles, plus invite-by-email. - Billing — Stripe Checkout, Customer Portal, and webhook-driven plan updates across three tiers.
- Public REST API — Bearer API keys under
/v1with a per-key rate limit. - Outgoing webhooks — signed, retried event delivery to customer endpoints.
- Audit log — every meaningful action recorded with actor, IP, and timestamp.
The app runs out of the box with just JWT_SECRET set. Email (Resend) and Stripe are optional — without their keys, billing buttons are disabled and emails are printed to the server console.
1. Install and configure
# Clone and enter the project
git clone <your-repo-url> saasly
cd saasly
# Copy the example environment file
cp .env.example .env
# Generate a strong JWT signing secret and paste it into .env as JWT_SECRET
openssl rand -base64 32
# Install dependencies
npm install2. Migrate and run
# Create the SQLite schema (also runs automatically on first boot)
npm run db:migrate
# Start the dev server on http://localhost:3000
npm run devOpen http://localhost:3000 and create your first account — the first user of an organization becomes its owner.
Environment variables
JWT_SECRET— required. Signs session JWTs; the app refuses to start in production without it.DB_PATH— SQLite file path (defaults to./local.db).NEXT_PUBLIC_APP_URLandAPP_NAME— used in email links and UI copy.RESEND_API_KEY/EMAIL_FROM— optional, for transactional email.STRIPE_SECRET_KEY,STRIPE_WEBHOOK_SECRET,STRIPE_PRO_PRICE_ID,STRIPE_ENTERPRISE_PRICE_ID— optional, for billing.
Sessions are stateless JWTs (signed with HS256 via jose) stored in an httpOnly, SameSite=Lax cookie named session. Tokens expire after 7 days and are marked Secure in production.
Revocable sessions
Each login also writes a row to the sessions table with the device IP and user agent. The token embeds that sessionId, so a session can be revoked server-side at any time — revoked or expired sessions are rejected on the next request, and last_active_at is touched on every check.
Two-factor authentication
2FA is optional per user and uses TOTP (RFC 6238) compatible with any authenticator app. Setup shows a QR code; on enrollment the user receives one-time recovery codes to store safely.
Email verification & password reset
Verification and reset flows issue single-use, expiring tokens delivered by email. With no RESEND_API_KEY set, those emails are printed to the server console so you can copy the link during local development.
Every user belongs to one or more organizations, and all data is scoped by org_id. There are three roles:
owner— full control, including billing and deleting the organization. The creator of an org is its first owner.admin— manage members, invitations, API keys, and webhooks.member— standard access to the product.
Invitations
Owners and admins invite teammates by email. Each invite generates a token-based acceptance link; the recipient joins the org on acceptance, subject to the plan's member limit. Invite, join, removal, and role-change events are all recorded in the audit log and emitted as webhooks.
The public API lives under /v1 and is authenticated with an API key passed as a Bearer token. Generate keys from the dashboard; only a hashed copy is stored, so the full key is shown once at creation.
curl https://api.example.com/v1/me \
-H "Authorization: Bearer sk_live_your_api_key"Keys are scoped to the organization that created them. Requests are limited to 60 requests per minute per key; over the limit returns 429 with Retry-After and X-RateLimit-* headers. Missing or invalid keys return 401.
GET /v1/me
Returns the authenticated user and their organization.
curl https://api.example.com/v1/me \
-H "Authorization: Bearer sk_live_your_api_key"{
"id": "usr_8Kx2mZq",
"email": "ada@acme.dev",
"name": "Ada Lovelace",
"org": {
"id": "org_4Tn9wb",
"name": "Acme Inc",
"slug": "acme",
"plan": "pro"
}
}GET /v1/members
Lists every member of the organization, oldest first.
curl https://api.example.com/v1/members \
-H "Authorization: Bearer sk_live_your_api_key"{
"members": [
{
"id": "usr_8Kx2mZq",
"name": "Ada Lovelace",
"email": "ada@acme.dev",
"role": "owner",
"joined_at": "2026-01-04T09:31:22.000Z"
},
{
"id": "usr_b3Pl0Rd",
"name": "Alan Turing",
"email": "alan@acme.dev",
"role": "member",
"joined_at": "2026-02-18T14:08:51.000Z"
}
]
}GET /v1/usage
Returns a 30-day breakdown of audit-log activity for the organization, grouped by action.
curl https://api.example.com/v1/usage \
-H "Authorization: Bearer sk_live_your_api_key"{
"window": "30d",
"total_events": 412,
"by_action": {
"api_key.used": 240,
"member.invited": 12,
"org.updated": 4
}
}Register endpoints in the dashboard to receive events as they happen. Each delivery is a POST with a JSON envelope and is retried with exponential backoff (~1m, 5m, 30m, 2h, 6h) for up to 5 attempts.
Event catalog
The following events are emitted. Subscribe to specific events or to all with *.
Request format
Each request carries identifying headers and an HMAC signature. The body is the raw JSON envelope — verify against exactly the bytes you receive.
POST /your-endpoint HTTP/1.1
Content-Type: application/json
User-Agent: Saasly-Webhooks/1.0
X-Webhook-Id: whd_8Kx2mZq...
X-Webhook-Event: member.invited
X-Webhook-Timestamp: 1717000000
X-Webhook-Signature: t=1717000000,v1=8f3a...c91b
{
"id": "evt_8Kx2mZq...",
"type": "member.invited",
"created_at": "2026-05-27T10:00:00.000Z",
"data": { "...": "event-specific payload" }
}Signature scheme
The X-Webhook-Signature header is Stripe/Svix-style. The signed payload is ${timestamp}.${rawBody}, and the header is t=${timestamp},v1=${hmac_sha256_hex} where the HMAC is keyed by your endpoint signing secret (a value starting with whsec_). Recompute the HMAC, compare in constant time, and reject timestamps outside a tolerance window (300s by default) to prevent replay.
Verifying in Node
import { createHmac, timingSafeEqual } from 'crypto';
// Verify an incoming webhook. Pass the RAW request body (not re-serialized
// JSON), the X-Webhook-Signature header, and your endpoint signing secret.
export function verifySignature(
secret: string,
header: string,
rawBody: string,
toleranceSec = 300,
): boolean {
// header looks like: t=1717000000,v1=<hex hmac>
const parts = Object.fromEntries(header.split(',').map((kv) => kv.split('=')));
const t = Number(parts['t']);
const v1 = parts['v1'];
if (!t || !v1) return false;
// Reject stale timestamps to prevent replay attacks.
if (Math.abs(Date.now() / 1000 - t) > toleranceSec) return false;
const expected = createHmac('sha256', secret)
.update(`${t}.${rawBody}`)
.digest('hex');
const a = Buffer.from(v1);
const b = Buffer.from(expected);
return a.length === b.length && timingSafeEqual(a, b);
}Billing is powered by Stripe: subscription Checkout for upgrades, the Customer Portal for self-serve plan and payment-method changes, and a webhook at /api/stripe/webhookthat keeps each organization's plan in sync. Without STRIPE_SECRET_KEY, the billing UI renders but checkout is disabled.
Plans
| Plan | Price / mo | Member limit |
|---|---|---|
| Starter | $0 | 3 |
| Pro | $29 | 25 |
| Enterprise | $99 | Unlimited |
The Starter plan is free and needs no Stripe configuration. For Pro and Enterprise, create recurring prices in Stripe and set STRIPE_PRO_PRICE_ID and STRIPE_ENTERPRISE_PRICE_ID. For local testing, forward events with stripe listen --forward-to localhost:3000/api/stripe/webhook.
This is your starting point — most products only need to swap branding, adjust the plans, and tweak copy. Here's where the knobs live:
- Branding — the product name and gradient logo live in
app/page.tsxand the shared layout; colors and typography are design tokens inapp/globals.css. - Plans & pricing — edit the
PLANSobject inlib/stripe.ts(names, prices, member limits, features), then wire the matching Stripe price IDs. - Email templates — change subject lines and HTML in
lib/email.ts. - These docs — edit
app/docs/page.tsxto match your product.
Swapping SQLite → Postgres
The app ships with SQLite (WAL mode) via Drizzle for a zero-config start. To move to Postgres, switch the Drizzle driver in lib/db.ts from better-sqlite3 to a Postgres driver (e.g. postgres with drizzle-orm/postgres-js), point DB_PATH/your connection string at the new database, and re-run the migrations. Note that a couple of raw queries (such as the /v1/usage 30-day window using datetime('now', '-30 days')) use SQLite syntax and need a Postgres equivalent.