DOCUMENTATION

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, and member roles, plus invite-by-email.
  • Billing — Stripe Checkout, Customer Portal, and webhook-driven plan updates across three tiers.
  • Public REST API — Bearer API keys under /v1 with 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

bash
# 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 install

2. Migrate and run

bash
# Create the SQLite schema (also runs automatically on first boot)
npm run db:migrate

# Start the dev server on http://localhost:3000
npm run dev

Open http://localhost:3000 and create your first account — the first user of an organization becomes its owner.

Environment variables

  • JWT_SECRETrequired. Signs session JWTs; the app refuses to start in production without it.
  • DB_PATH — SQLite file path (defaults to ./local.db).
  • NEXT_PUBLIC_APP_URL and APP_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.

bash
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.

bash
curl https://api.example.com/v1/me \
  -H "Authorization: Bearer sk_live_your_api_key"
json
{
  "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.

bash
curl https://api.example.com/v1/members \
  -H "Authorization: Bearer sk_live_your_api_key"
json
{
  "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.

bash
curl https://api.example.com/v1/usage \
  -H "Authorization: Bearer sk_live_your_api_key"
json
{
  "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 *.

user.createdorg.createdorg.updatedmember.invitedmember.joinedmember.removedmember.role_changedapi_key.createdapi_key.revokedbilling.subscription_updated

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.

http
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

ts
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

PlanPrice / moMember limit
Starter$03
Pro$2925
Enterprise$99Unlimited

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.tsx and the shared layout; colors and typography are design tokens in app/globals.css.
  • Plans & pricing — edit the PLANS object in lib/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.tsx to 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.