OptinStack

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 keyDisplay nameMonthly price (USD)
liteLite$0
proPro$7
businessBusiness$15
enterpriseEnterpriseCustom

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/pricing

The 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:

  1. Platform subdomain exemption — Hostnames on .framer.app, .webflow.io, etc. stay on lite for preview URLs.
  2. No active subscription — Returns lite.
  3. Active Paddle subscription — Returns the subscription tier (pro or business).
  4. Past due / canceled — Downgrades to lite after grace rules apply.

Routes use requireTier(...tiers) middleware, which returns 403 when the project tier is too low.

Enforced limits (summary)

FeatureLiteProBusiness
Pages per scan30100350
Scheduled scans
Geo-targeting (custom domain)Global onlyGlobal 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' }).

FilePurpose
packages/utils/src/helpers/pricing-config.tsPricing SSOT
packages/server/src/lib/pricing.tsRe-export + SCAN_PAGE_LIMIT
packages/server/src/route/v1/pricing.tsGET /v1/pricing
packages/server/src/services/v1/license.tsresolveEffectiveTier
packages/server/src/middleware/license.tsrequireTier
packages/server/src/route/v1/webhooks.tsPaddle webhook handler
packages/apps/core/src/hooks/use-pricing.tsDashboard pricing hook

On this page