# Cloud Deployment (/docs/developer-guide/cloud-deployment)

`DEPLOYMENT_MODE=cloud` runs the managed, multi-tenant SaaS offering: self-serve signup, one umbrella
organization per customer holding all their brands, Stripe subscription billing, and plan-enforced
limits. A local deployment using WorkOS can opt into the same billing and entitlement system.
Other local deployments, demo, and whitelabel retain unlimited entitlements.

## WorkOS with Stripe billing [#workos-with-stripe-billing]

Keep `DEPLOYMENT_MODE=local` and `AUTH_PROVIDER=workos` to preserve the existing WorkOS Google
sign-in and organization provisioning. Set `BILLING_ENABLED=true` on **both web and worker** so
checkout, API limits, and scheduled tracking agree. This setting does not change the login provider.

The web service also needs `STRIPE_SECRET_KEY` and `STRIPE_WEBHOOK_SECRET`. Prefer a restricted
Stripe key with the permissions required for customers, subscriptions, Checkout, prices, and the
customer portal. Keep these credentials server-side; the worker only needs the billing setting to
read subscription state from the database. Register `<APP_URL>/api/auth/stripe/webhook` with the
subscription lifecycle events listed below. The existing subscription and organization tables are
reused; enabling billing does not migrate users or organizations.

Once enabled, organizations without an active subscription must select a plan before entering the
product. Existing data remains in its organization. Configure any custom agreements through the
existing entitlement overrides before enabling billing for those organizations.

## Required environment [#required-environment]

On top of the shared variables (`DATABASE_URL`, `BETTER_AUTH_SECRET`, `SCRAPE_TARGETS`,
`DEPLOYMENT_MODE`), cloud requires:

| Variable | Purpose |
| --- | --- |
| `APP_URL` | Public base URL (auth, email links, Stripe redirects) |
| `STRIPE_SECRET_KEY` | Stripe API key (`sk_live_...` / `sk_test_...`) |
| `STRIPE_WEBHOOK_SECRET` | Signing secret for the billing webhook |
| `RESEND_API_KEY`, `RESEND_FROM_EMAIL` | Transactional email |
| `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET` | Google sign-in |
| `CLOUD_SIGNUP_ALLOWLIST` | Signup gate — empty denies everyone, `*` opens signup |

Strongly recommended:

| Variable | Purpose |
| --- | --- |
| `TURNSTILE_SECRET_KEY`, `VITE_TURNSTILE_SITE_KEY` | Bot protection on email sign-up, sign-in, and password reset |

## Bot protection [#bot-protection]

Email sign-up, sign-in, password reset, and verification-email resend all send mail to whatever
address the caller types, so they are guarded with [Cloudflare Turnstile](https://developers.cloudflare.com/turnstile/)
through better-auth's `captcha` plugin. Google sign-in, API keys, and MCP/OAuth are not challenged.

Create a Turnstile widget in the Cloudflare dashboard for the app's hostname (Managed mode), then
set its site key as `VITE_TURNSTILE_SITE_KEY` and its secret as `TURNSTILE_SECRET_KEY`. The check is
enforced only while both are set; otherwise the app logs a warning at startup and the forms work
unchallenged. Tokens are only accepted when Cloudflare reports they were solved on the hostname in
`APP_URL`, so the widget's hostname list must include it.

For local development, use Cloudflare's [test keys](https://developers.cloudflare.com/turnstile/troubleshooting/testing/),
which always pass and skip the hostname check:

```bash
VITE_TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
```

Site key `2x00000000000000000000AB` always fails and `3x00000000000000000000FF` forces the
interactive checkbox, for trying the error and challenge paths.

## Stripe setup [#stripe-setup]

The plan catalog lives in `packages/config/src/plans.ts` — prices, limits, sampling rates, and the
premium pool per plan. The application references Stripe prices by **lookup key**, never by price id,
so one bootstrap command provisions any Stripe account (test or live) to match the catalog:

```bash
STRIPE_SECRET_KEY=sk_test_... pnpm -C packages/deployment exec tsx scripts/bootstrap-stripe.ts
```

Re-run it after editing the catalog; it only creates what's missing (Stripe prices are immutable, so
amount changes create a new price and move the lookup key). Then configure a webhook endpoint in the
Stripe dashboard:

- URL: `<APP_URL>/api/auth/stripe/webhook`
- Events: `checkout.session.completed`, `customer.subscription.created`,
  `customer.subscription.updated`, `customer.subscription.deleted`

The webhook-maintained `subscription` table is the single source of truth for billing state; both
the web app and the worker read it on every decision, so plan changes, payment failures, and
cancellations apply without restarts. A payment failure keeps tracking alive for a 7-day grace
window, then pauses tracking (data stays readable) until Stripe recovers the payment.

The webhook also emails the org's admins on payment failure, recovery, and subscription end. These
notices go through the same `RESEND_API_KEY` / `RESEND_FROM_EMAIL` configuration as the auth
emails — no additional setup.

Enable Stripe Tax and Smart Retries in the Stripe dashboard. Before launch, walk the full lifecycle
against a test-clock customer with `packages/deployment/scripts/verify-stripe-lifecycle.ts` (see its
header for usage).

### Coupons [#coupons]

Checkout offers an "Add promotion code" field, so a launch or partner code needs no code change —
create the coupon and its promotion code in the Stripe dashboard (Product catalog → Coupons) and
hand out the code. Restrict it there to the plans, durations, or first-time customers it should
apply to; the app never sees the code and does not validate it.

Two limits worth knowing:

- Only **first** checkout takes a code. A customer already on a plan switches through Stripe's
  subscription API, which has no promotion-code step, so discounting an existing subscriber means
  applying the coupon to their subscription in the dashboard.
- To let customers redeem codes themselves later, turn on promotion codes in the Customer Portal
  settings too — it is a separate dashboard toggle from the one on Checkout.

To verify, create a test-mode promotion code and subscribe once through the app.

## SCRAPE_TARGETS for cloud [#scrape_targets-for-cloud]

The instance-level `SCRAPE_TARGETS` defines the menu customers pick from. Configure **one target per
standard platform** (the run policy uses the first match per model), plus a grounded target for each
model sold in the premium tier:

```
chatgpt:brightdata:online,google-ai-mode:brightdata:online,google-ai-overview:brightdata:online,copilot:brightdata:online,perplexity:brightdata:online,gemini:brightdata:online,qwen:openrouter:qwen/qwen3-235b-a22b-2507,deepseek:openrouter:deepseek/deepseek-v3.2,mistral:openrouter:mistralai/mistral-medium-3.1,claude:anthropic-api:claude-haiku-5-5,kimi:openrouter:moonshotai/kimi-k2.5,chatgpt:openai-api:gpt-6-luna:online,grok:openrouter:x-ai/grok-4.5:online,claude:anthropic-api:claude-haiku-5-5:online
```

Platforms are offered to customers only when they appear in both the plan menu
(`STANDARD_PLATFORM_MENU`) and `SCRAPE_TARGETS`.

### Supported model versions [#supported-model-versions]

These are the exact versions Beai Cloud runs. Scraped platforms have no version to pin: they track
whatever the live product serves its users that day.

| Platform | Sold as | Version |
| --- | --- | --- |
| ChatGPT | Pick (scraped) | Live chatgpt.com |
| ChatGPT | Premium | `gpt-6-luna` with web search, via the OpenAI API |
| Google AI Mode | Pick (scraped) | Live product |
| Google AI Overviews | Pick (scraped) | Live product |
| Copilot | Pick (scraped) | Live product |
| Perplexity | Pick (scraped) | Live product |
| Gemini | Pick (scraped) | Live gemini.google.com |
| Qwen | Pick (API) | `qwen/qwen3-235b-a22b-2507`, via OpenRouter |
| DeepSeek | Pick (API) | `deepseek/deepseek-v3.2`, via OpenRouter |
| Mistral | Pick (API) | `mistralai/mistral-medium-3.1`, via OpenRouter |
| Claude | Pick (API) | `claude-haiku-5-5`, via the Anthropic API |
| Claude | Premium | `claude-haiku-5-5` with web search, via the Anthropic API |
| Grok | Premium | `x-ai/grok-4.5` with web search, via OpenRouter |
| Kimi | Pick (API) | `moonshotai/kimi-k2.5`, via OpenRouter |

API picks are priced against a design point of roughly **$0.004 per run**, which is why Kimi runs
K2.5 rather than K3: K3's output tokens cost several times that per answer. Check a new model's
per-run price against that point before adding it to the menu or bumping a version.

Some models are configured twice on purpose, and the two entries are sold differently:

- **Ungrounded** (`claude:anthropic-api:...`) is an ordinary platform pick on every standard plan. A
  pick always resolves the ungrounded target, never the grounded one, so a brand cannot spend the
  premium pool by choosing a platform.
- **Grounded** (`...:online` on an API provider) is the premium tier, counted against the org's pool
  of prompt/model pairings (`prompts.premium_models`) — plan included plus purchased add-on. A
  grounded call is roughly ten times an ungrounded one, which is why it is metered per pairing rather
  than per brand. Customers assign it in the prompts editor; the LLM settings page reports the totals.

The two tiers are independent, not two rungs of one ladder. A brand's picks run on **every** one of
its prompts; a premium model is chosen on **individual** prompts and runs in addition to them. One
prompt might carry Claude and Grok while the next carries only Grok and a third carries none, and all
three still run the brand's four picks. A premium model need not be picked, and picking a model does
not discount its premium slot — `resolvePromptRunPlan` builds the two sets from different inputs
(`brand.enabledModels` and `prompt.premiumModels`) and simply concatenates them.

Which models are sellable that way is a `premium` flag in `CLOUD_PLATFORMS`, not a model id threaded
through the code: today Claude, Grok and ChatGPT (GPT Search). Adding another means adding the flag
and a grounded target — nothing in the schema, the guards or the run policy names a model.

"Grounded" means **a direct API call with the model's own web search on**, which is what
`isGroundedApiTarget` decides. Web search alone doesn't say it: every scraped target is `:online`
too, because a consumer surface always searches. It is the combination that costs.

Every pick samples at the plan's rate, whether it is scraped or an API call — a model is not premium,
a grounded call is. Only the grounded targets depart, at `PREMIUM_RUNS_PER_DAY`, which a custom plan
can raise through the `premiumRunsPerDay` override.

Qwen, DeepSeek, Mistral and Kimi carry no `:online` variant at all: they have no native search tool, so
OpenRouter would fall back to Exa, whose results are not a surface a consumer would ever see. They
report mentions but no citations, since a run with no web search returns no source annotations — as
does any ungrounded pick.

Grok is sold **only** as a premium target, grounded — it is the one model on this list with no
ungrounded entry in the cloud menu. Its own answers run about **$0.004 per call** against a plan
design point near **$0.004 per run**, because OpenRouter prices Grok's output at several times a
scrape and Beai pays for output tokens, not queries. One of four picks devoted to it, sampled four
times a day, would cost more than the scraped surface it displaced. Metered once daily out of the
paid pool, it prices the same way grounded Claude does.

The customer-facing version of this distinction is how the LLM settings page is laid out: three cards
— **Scraped Engines**, **LLM APIs** and **Premium** — each explaining in its own
description what that group tells you about a brand, with the pick budget and one save bar shared
across them. Providers declare which they are; DataForSEO declares both and picks per target, since
pinning a model version routes to LLM Responses instead of the scraper.

The third card behaves differently per mode. In cloud it reports the premium pool rather than offering
checkboxes, because grounded calls are metered per prompt/model pair. Self-hosted has no pool, so a
grounded target there is an ordinary pick.

Those tooltips also carry a per-run cost estimate and a monthly projection for the brand — but only
in `local` mode, where the operator pays the providers. Cloud and whitelabel omit it: the person
looking pays a plan price rather than the bills, and the per-provider numbers behind it are internal
attribution estimates (`packages/lib/src/usage/cost.ts`), not quotes.

Local mode also lists the platforms this instance has **not** configured, with the providers that can
serve each and a link into that provider's section of the setup guide. The suggestions come from
`providersByModel()`, derived from `STATUS_TARGETS`, so every one is a combination the scheduled
provider workflow exercises. A test asserts the reverse too: every model in `KNOWN_MODELS` has a
status target, so a platform can't ship on the menu without something watching whether it still
works.

## How plan limits are enforced [#how-plan-limits-are-enforced]

Three layers, all resolving through `getOrgEntitlements(orgId)`:

1. **Write time** — brand count, the org-wide tracked-prompt pool, platform picks, and premium
   assignments are checked in the web server functions and `/api/v1` before any insert.
2. **Schedule time** — every `process-prompt` firing re-resolves the org's entitlements and runs
   only targets that are due at their plan cadence (standard platforms at the plan's samples/day,
   premium targets once daily), metered against actual `prompt_runs` history. Canceled or paused orgs stop
   running; `schedule-maintenance` revives tracking within minutes of reactivation. A per-org daily
   run ceiling derived from plan limits caps runaway spend.
3. **Downgrades** — nothing is deleted. Resources beyond the new limits simply stop running,
   oldest-first wins, and the billing page shows the overage.

Every provider call is recorded in `usage_events` with the org, brand, prompt, target, and a rough
cost estimate (tune the per-provider numbers in `packages/lib/src/usage/cost.ts` against real
invoices).

## Custom plans [#custom-plans]

Custom plans are configuration, not code. Insert (or update) a row in `organization_settings`:

```sql
INSERT INTO organization_settings (organization_id, entitlement_overrides)
VALUES ('<org-id>', '{
  "planOverride": "custom",
  "maxBrands": 10,
  "maxPrompts": 1000,
  "platformPicks": 6,
  "extraPlatforms": ["kimi"],
  "standardRunsPerDay": 7,
  "premiumPoolIncluded": 100
}')
ON CONFLICT (organization_id) DO UPDATE SET entitlement_overrides = EXCLUDED.entitlement_overrides;
```

Every field is optional and overrides the org's plan (or the Business plan when `planOverride:
"custom"` marks an org billed outside self-serve — such an org is fully entitled without any Stripe
subscription). The shape is validated by `entitlementOverridesSchema` in
`packages/config/src/entitlements.ts`; malformed JSON falls back to plain plan limits, never to
unlimited. `extraPlatforms` entries must also exist in `SCRAPE_TARGETS` to actually run.

## Notes for self-hosted upgrades [#notes-for-self-hosted-upgrades]

The cloud build-out ships in the same migrations every deployment runs. Plan limits do not: outside
cloud, entitlements resolve to unlimited before any query, so every write-time guard and the
worker's run policy short-circuit and behavior is unchanged. `subscription`,
`organization_settings` and `prompts.premium_models` sit empty or unused.

Two deliberate improvements come along for the ride: the LLMs settings page edits
`brands.enabledModels` directly, and a prompt re-fired early (e.g. by the self-healing scheduler
after a provider outage) re-runs only the targets that are actually stale instead of re-sampling
every model.

Two things do change for every install. `usage_events` is written in all modes — one row per
provider call, success or failure — because it is the per-run attribution and provider failure-rate
record, not a billing-only table. It has no retention job, so it grows with run volume. And
migration 0014 deletes duplicate `member` rows before adding the unique index it should always have
had; whitelabel installs are the ones likely to have any, since the Auth0 sync could race itself.
It keeps the most-privileged, then oldest, row per (organization, user) and is verified against a
seeded database in CI.
