Stripe billing
Configure Stripe for workspace subscriptions, media storage quotas, checkout, and webhooks in OpenQuok — including self-host with billing disabled.
Connect your agent today
Draft from chat, review in your calendar, and publish only what you approve.
Overview
OpenQuok uses Stripe for workspace (organization) subscriptions. Plan limits are defined in openquok-common.
Plan enforcement (limits + feature gates) lives in the backend guards module:
| Concern | Location |
|---|---|
| Guard service (plan evaluation + assertions) | backend/guards/subscription/SubscriptionGuardService.ts |
Route guard middlewares (requirePlanCapability, requireAccountPlanCapability) | backend/guards/subscription/middleware.ts |
| Surface | Role |
|---|---|
| Backend | Checkout, customer portal, webhooks, subscription rows in Postgres |
| Web | /account/billing — current plan, usage, upgrade |
| Database | organization_subscriptions, organizations.stripe_customer_id, organizations.allow_trial, organizations.is_trialing (organization + billing migrations) |
Environment variables
Backend environment variables
Set these in backend/.env.development.local (or your host’s secret store in production). They are read only from backend/config/GlobalConfig.ts.
STRIPE_PUBLISHABLE_KEY=pk_test_your_publishable_key
STRIPE_SECRET_KEY=sk_test_your_secret_key
STRIPE_WEBHOOK_SECRET=whsec_your_webhook_signing_secret | Variable | Used for Stripe API calls? | Role |
|---|---|---|
| STRIPE_SECRET_KEY | Yes | Checkout sessions, Customer Portal, price lookup, server-side Stripe SDK |
| STRIPE_WEBHOOK_SECRET | Yes (signatures) | Verify webhook payloads on POST /api/v1/billing/webhooks/stripe |
| STRIPE_PUBLISHABLE_KEY | No | Billing mode flag — see below |
Also ensure FRONTEND_DOMAIN_URL matches the origin where users open the app (for example https://localhost:5173 in local HTTPS dev). Checkout success and cancel URLs point to /account/billing on that host.
Templates: backend/.env.development.example.
Billing mode
Backend STRIPE_PUBLISHABLE_KEY is not sent to Stripe. It only answers: is paid billing enabled? (billingEnabled).
Self-hosted or local
Leave STRIPE_PUBLISHABLE_KEY unset (you may also omit STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET if you are not testing Stripe at all).
| Behavior | Effect |
|---|---|
billingEnabled | false |
| Effective tier (no organization_subscriptions row) | SOLO — see SubscriptionService.resolveTier |
| Plan limits | SOLO limits from pricing.ts (used for display/quota math when a value is needed) |
| SubscriptionGuardService.assert | Skipped — subscription policy checks do not run |
| First Billing paywall | Does not show — FirstBillingGate only runs when billing is enabled and the tier is FREE |
| /account/billing | Shows an info alert: billing is not configured; no upgrade cards |
Use this for contributors, Docker self-host, or internal instances where Stripe is intentionally disabled. Empty publishable key is the self-host / local default: no paywall, guards off.
SaaS or production
Set all three backend Stripe variables. With STRIPE_PUBLISHABLE_KEY set:
| Behavior | Effect |
|---|---|
billingEnabled | true |
| Effective tier | FREE — paid limits until the user subscribes |
| Plan limits | Enforced from the active tier (FREE or paid plans) |
| SubscriptionGuardService.assert | Active — storage, team seats, share preview, public API, and related gates |
| /account/billing | Upgrade UI, hosted Checkout redirect (existing subs), Customer Portal when a Stripe customer exists |
Checkout and webhooks still require STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.
Two checkout paths
Embedded (first-billing paywall): Stripe.js with VITE_PUBLIC_STRIPE_PUBLISHABLE_KEY. Session from POST /api/v1/billing/embedded.
Redirect (account billing upgrades): hosted Checkout URL from POST /api/v1/billing/subscribe.
Backend vs web publishable key
Backend STRIPE_PUBLISHABLE_KEY only toggles billingEnabled. It is never passed to loadStripe().
For SaaS with first-billing, set the same publishable key on the web as VITE_PUBLIC_STRIPE_PUBLISHABLE_KEY.
Web publishable key
VITE_PUBLIC_STRIPE_PUBLISHABLE_KEY= Required for embedded Checkout.
billingEnabled still comes from the API (backend STRIPE_PUBLISHABLE_KEY), not from this Vite variable. Leave both empty for self-host.For SaaS, set the same publishable key on backend and web.
This key also gates PostHog product analytics when empty — see Product analytics.
Price IDs
Create recurring prices in the Stripe Dashboard for each paid tier (SOLO, TEAM, ULTIMATE, 10x Max) and cadence (monthly / yearly). Put the same price_… ids on web and backend.
Web (web/.env.development.local, resolved by web/src/lib/billing/constants/config.ts):
VITE_PUBLIC_STRIPE_PRICE_ID_SOLO_MONTHLY=price_...
VITE_PUBLIC_STRIPE_PRICE_ID_SOLO_YEARLY=price_...
VITE_PUBLIC_STRIPE_PRICE_ID_TEAM_MONTHLY=price_...
VITE_PUBLIC_STRIPE_PRICE_ID_TEAM_YEARLY=price_...
VITE_PUBLIC_STRIPE_PRICE_ID_ULTIMATE_MONTHLY=price_...
VITE_PUBLIC_STRIPE_PRICE_ID_ULTIMATE_YEARLY=price_...
VITE_PUBLIC_STRIPE_PRICE_ID_MAX_MONTHLY=price_...
VITE_PUBLIC_STRIPE_PRICE_ID_MAX_YEARLY=price_... Backend (backend/.env.development.local) — used by POST /api/v1/billing/prorate:
STRIPE_PRICE_ID_SOLO_MONTHLY=price_...
STRIPE_PRICE_ID_SOLO_YEARLY=price_...
STRIPE_PRICE_ID_TEAM_MONTHLY=price_...
STRIPE_PRICE_ID_TEAM_YEARLY=price_...
STRIPE_PRICE_ID_ULTIMATE_MONTHLY=price_...
STRIPE_PRICE_ID_ULTIMATE_YEARLY=price_...
STRIPE_PRICE_ID_MAX_MONTHLY=price_...
STRIPE_PRICE_ID_MAX_YEARLY=price_... Plan limits are edited in pricing.ts, not in Stripe metadata.
Steps for Stripe Dashboard
Note
Create API keys
In the Stripe Dashboard, open Developers → API keys.
Copy:
- Publishable key → STRIPE_PUBLISHABLE_KEY (billing mode flag; required for SaaS-style enforcement). Mirror the same value on web as VITE_PUBLIC_STRIPE_PUBLISHABLE_KEY for embedded Checkout (first-billing).
- Secret key → STRIPE_SECRET_KEY (required for Checkout, Portal, and API calls).
Configure the Customer Portal
Open Customer portal settings (test) or Customer portal settings (live) and save a default configuration (payment method updates, cancellation, and so on). The API opens the portal with return URL built from FRONTEND_DOMAIN_URL plus /account/billing.
Create a webhook endpoint
In Developers → Webhooks, add an endpoint whose URL is your public API base plus:
/api/v1/billing/webhooks/stripe Production (API on its own host):
https://api.yourdomain.com/api/v1/billing/webhooks/stripe Local (Stripe CLI only) — use forwarding below; do not register localhost in the Dashboard.
Subscribe at least these events:
customer.subscription.created
customer.subscription.updated
customer.subscription.deleted Warning
whsec_). Copy that value to STRIPE_WEBHOOK_SECRET. Do NOT paste the webhook id (we_...).Align frontend and backend origins
Set FRONTEND_DOMAIN_URL and VITE_FRONTEND_DOMAIN_URL to the same scheme and host users use in the browser.
Local webhook testing (Stripe CLI)
Stripe cannot call localhost directly. Use the Stripe CLI to forward events to your API process (default port 3000).
Install and log in
Install the Stripe CLI, then:
stripe login Forward events to the API
With the backend running on port 3000:
stripe listen --forward-to localhost:3000/api/v1/billing/webhooks/stripe The CLI prints a webhook signing secret for this session. Put that value in STRIPE_WEBHOOK_SECRET in backend/.env.development.local and restart the API.
CLI vs Dashboard secret
stripe listen is **different** from the secret on a Dashboard webhook endpoint. Use the CLI secret only while forwarding locally; use the Dashboard whsec_ in deployed environments.Trigger a test subscription flow
- Open the web app → Account → Billing (/account/billing).
- Choose a plan (for example SOLO) and complete Stripe Checkout (test card 4242 4242 4242 4242).
- Confirm the CLI shows customer.subscription.created (or customer.subscription.updated) and the API returns
200. - Refresh billing: tier and media storage quota should match the plan in openquok-common pricing.
How plans map to product behavior
| Concern | Where it is defined |
|---|---|
| Tier names, limits, and USD amounts | common/src/subscription/pricing.ts |
Stripe Checkout price_… | Web VITE_PUBLIC_STRIPE_PRICE_ID_SOLO_MONTHLY (one key per tier and cadence; see env list above) |
| Media library total bytes per workspace | media_storage_bytes_per_workspace on each tier |
| Per-file upload cap | 30 MB images / 1 GB videos (frontend); 10 MB images / 1 GB videos (API); separate from storage quota |
| Active subscription row | organization_subscriptions |
| Stripe customer id | organizations.stripe_customer_id |
Tiers: FREE(authenticated, no paid row), SOLO, TEAM, ULTIMATE ($69/mo), MAX (10x Max, $129/mo).Changing features or list prices is a code change in pricing.ts plus matching Stripe prices in the Dashboard.
API routes (reference)
| Method | Path | Purpose |
|---|---|---|
GET | /api/v1/billing/plans | Public plan catalog and limits |
GET | /api/v1/billing/current?organizationId= | Tier, drive usage, trial flags |
POST | /api/v1/billing/embedded | Create embedded Checkout session (client secret for Stripe.js) |
POST | /api/v1/billing/subscribe | Hosted Checkout redirect or upgrade existing sub |
GET | /api/v1/billing/portal?organizationId= | Stripe Customer Portal URL |
GET | /api/v1/billing/check/:id?organizationId= | Poll after Checkout return |
POST | /api/v1/billing/webhooks/stripe | Stripe webhooks (raw body; no JWT) |
Webhook requests must use the raw JSON body for signature verification; the route is registered before the global JSON parser.
Production checklist
Use live keys
Switch Dashboard to Live mode, create live API keys and a live webhook endpoint (same path suffix), and set live values in your host’s secrets.
Confirm webhook delivery
In Stripe → Webhooks → your endpoint, verify recent events show Succeeded. Failed deliveries usually mean wrong STRIPE_WEBHOOK_SECRET, a blocked URL, or TLS issues.
Verify billing UI
Signed-in users with a workspace should see usage on Billing and in the media library sidebar (used / total from the subscription tier).
Troubleshooting
| Symptom | Likely cause |
|---|---|
| Billing page says Stripe is not configured | STRIPE_PUBLISHABLE_KEY empty on the API (billingEnabled: false) — expected for self-host; set it for SaaS |
| Self-host wants full product without Stripe | Leave STRIPE_PUBLISHABLE_KEY unset; effective tier SOLO, guards skipped, no First Billing paywall |
| SaaS user stuck on FREE limits | Set STRIPE_PUBLISHABLE_KEY and complete Checkout; confirm webhooks update organization_subscriptions |
| Checkout works but tier never updates | Webhook not reaching the API; wrong signing secret; migrations not applied |
402 on media upload | Workspace over media_storage_bytes_per_workspace for its tier |
Webhook 400 / signature error | Body parsed as JSON before the webhook handler; use CLI forward URL on port 3000 |