Subscriptions & Licensing
How project tiers, subscription syncing, and feature gating work in OptinStack.
OptinStack uses a tiered licensing model. Every project has an effective tier (lite, pro, business, or enterprise) that controls which features are available. Paid tiers are stored in the subscriptions table and synced from Paddle Billing webhooks.
Overview
┌─────────────────┐ webhook ┌──────────────────────────┐
│ Paddle │ ───────────────► │ POST /v1/webhooks/paddle │
│ checkout │ └──────────────────────────┘
└─────────────────┘ │
▼
┌──────────────────────────┐
│ subscription-sync │
└──────────────────────────┘
│
┌────────────────────┘
▼
┌─────────────────────┐ ┌──────────────────┐
│ subscriptions │ │ projects.tier │
│ (source of truth) │────►│ (computed cache)│
└─────────────────────┘ └──────────────────┘
│
▼
┌─────────────────────┐
│ R2 customMetadata │
│ { tier } │
└─────────────────────┘
│
▼
┌─────────────────────┐
│ CDN /optinstack.js │
│ window.optinstack │
│ .meta.tier │
└─────────────────────┘Tier names
Use these tier keys everywhere in code, API responses, and the dashboard:
| Tier key | Display name | Monthly price (USD) |
|---|---|---|
lite | Lite | $0 |
pro | Pro | $7 |
business | Business | $15 |
enterprise | Enterprise | Custom |
Annual billing is available in-app at reduced per-month rates ($5/mo Pro, $12/mo Business when billed yearly).
Pricing source of truth
Prices, scan limits, and the public feature matrix live in one shared config:
// packages/utils/src/helpers/pricing-config.ts
export const PRICING_CONFIG: PricingConfig = { ... };The server re-exports it and exposes it publicly:
curl https://api.optinstack.com/v1/pricingThe dashboard uses usePricing(); the marketing site imports the same config at build time so public pricing matches checkout.
Effective tier resolution
resolveEffectiveTier(db, projectId, hostname?) computes the real tier at request time:
- Platform subdomain exemption — Hostnames on
.framer.app,.webflow.io, etc. stay onlitefor preview URLs. - No active subscription — Returns
lite. - Active Paddle subscription — Returns the subscription tier (
proorbusiness). - Past due / canceled — Downgrades to
liteafter grace rules apply.
Routes use requireTier(...tiers) middleware, which returns 403 when the project tier is too low.
Enforced limits (summary)
| Feature | Lite | Pro | Business |
|---|---|---|---|
| Pages per scan | 30 | 100 | 350 |
| Scheduled scans | — | — | ✅ |
| Geo-targeting (custom domain) | Global only | Global only | ✅ |
| CSV / PDF export | — | ✅ | ✅ |
| Consent & site analytics | — | — | ✅ |
| Custom domains | ✅ | ✅ | ✅ |
Not enforced today: domain count caps, consent-records-per-month caps, team invites, customer API, or webhooks. Do not document those as tier limits until implemented.
CDN integration
When a config is published to R2, customMetadata.tier is set from the project's effective tier. The CDN loader injects:
window.optinstackMeta = {
tier: 'pro',
region: 'US',
isEu: false,
domain: 'example.com',
// ...
};Testing & simulation
POST /v1/projects/:projectId/tier/simulate (non-production, editor auth) upserts a synthetic subscription for QA.
Playwright tests can set tier via loadOptinStack(page, 'opt-in', { tier: 'business' }).
Related files
| File | Purpose |
|---|---|
packages/utils/src/helpers/pricing-config.ts | Pricing SSOT |
packages/server/src/lib/pricing.ts | Re-export + SCAN_PAGE_LIMIT |
packages/server/src/route/v1/pricing.ts | GET /v1/pricing |
packages/server/src/services/v1/license.ts | resolveEffectiveTier |
packages/server/src/middleware/license.ts | requireTier |
packages/server/src/route/v1/webhooks.ts | Paddle webhook handler |
packages/apps/core/src/hooks/use-pricing.ts | Dashboard pricing hook |