Skip to content

Stripe billing

Configure Stripe for workspace subscriptions, media storage quotas, checkout, and webhooks in OpenQuok — including self-host with billing disabled.

8 min read

Connect your agent today

Draft from chat, review in your calendar, and publish only what you approve.

Start for $0

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:

ConcernLocation
Guard service (plan evaluation + assertions)backend/guards/subscription/SubscriptionGuardService.ts
Route guard middlewares (requirePlanCapability, requireAccountPlanCapability)backend/guards/subscription/middleware.ts
SurfaceRole
BackendCheckout, customer portal, webhooks, subscription rows in Postgres
Web/account/billing — current plan, usage, upgrade
Databaseorganization_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
VariableUsed for Stripe API calls?Role
STRIPE_SECRET_KEYYesCheckout sessions, Customer Portal, price lookup, server-side Stripe SDK
STRIPE_WEBHOOK_SECRETYes (signatures)Verify webhook payloads on POST /api/v1/billing/webhooks/stripe
STRIPE_PUBLISHABLE_KEYNoBilling 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).

BehaviorEffect
billingEnabledfalse
Effective tier (no organization_subscriptions row)SOLO — see SubscriptionService.resolveTier
Plan limitsSOLO limits from pricing.ts (used for display/quota math when a value is needed)
SubscriptionGuardService.assertSkipped — subscription policy checks do not run
First Billing paywallDoes not showFirstBillingGate only runs when billing is enabled and the tier is FREE
/account/billingShows 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:

BehaviorEffect
billingEnabledtrue
Effective tierFREE — paid limits until the user subscribes
Plan limitsEnforced from the active tier (FREE or paid plans)
SubscriptionGuardService.assertActive — storage, team seats, share preview, public API, and related gates
/account/billingUpgrade 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.

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

Create API keys

In the Stripe Dashboard, open DevelopersAPI keys.

Copy:

  • Publishable keySTRIPE_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 keySTRIPE_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 DevelopersWebhooks, 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

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.

Trigger a test subscription flow

  1. Open the web app → AccountBilling (/account/billing).
  2. Choose a plan (for example SOLO) and complete Stripe Checkout (test card 4242 4242 4242 4242).
  3. Confirm the CLI shows customer.subscription.created (or customer.subscription.updated) and the API returns 200.
  4. Refresh billing: tier and media storage quota should match the plan in openquok-common pricing.

How plans map to product behavior

ConcernWhere it is defined
Tier names, limits, and USD amountscommon/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 workspacemedia_storage_bytes_per_workspace on each tier
Per-file upload cap30 MB images / 1 GB videos (frontend); 10 MB images / 1 GB videos (API); separate from storage quota
Active subscription roworganization_subscriptions
Stripe customer idorganizations.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)

MethodPathPurpose
GET/api/v1/billing/plansPublic plan catalog and limits
GET/api/v1/billing/current?organizationId=Tier, drive usage, trial flags
POST/api/v1/billing/embeddedCreate embedded Checkout session (client secret for Stripe.js)
POST/api/v1/billing/subscribeHosted 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/stripeStripe 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

SymptomLikely cause
Billing page says Stripe is not configuredSTRIPE_PUBLISHABLE_KEY empty on the API (billingEnabled: false) — expected for self-host; set it for SaaS
Self-host wants full product without StripeLeave STRIPE_PUBLISHABLE_KEY unset; effective tier SOLO, guards skipped, no First Billing paywall
SaaS user stuck on FREE limitsSet STRIPE_PUBLISHABLE_KEY and complete Checkout; confirm webhooks update organization_subscriptions
Checkout works but tier never updatesWebhook not reaching the API; wrong signing secret; migrations not applied
402 on media uploadWorkspace over media_storage_bytes_per_workspace for its tier
Webhook 400 / signature errorBody parsed as JSON before the webhook handler; use CLI forward URL on port 3000
Search documentation
Find a docs page
Discord Support