Plans, allowances, credits and retention
How plans, trials, allowances and credits work, and how to set the provider up.
The catalog itself lives in apps/api/src/billing/plans.ts; this page explains the moving parts around it.
What a workspace has
Every workspace holds one entitlement row per product (WorkspaceEntitlement): a plan (free, starter, team, business, enterprise), a status, and the billing state behind it.
Plans are per workspace, never per seat.
A Suite subscription writes the same plan onto both products.
The plan in force is resolved lazily from the row (LimitsService.effectiveOf), so nothing needs a nightly job:
- a trial is Team for 14 days; after
trialEndsAtthe workspace is Free again; past_duekeeps the plan for 14 days (graceUntil), then Free;cancelledkeeps the plan untilcancelAt(the period end), then Free.
Allowances and limits
Allowances count in calendar months, from the ledger and the rows themselves, never from a stored counter.
Each check lives in LimitsService and is called by the service that does the thing:
| Limit | Where it is checked | Beyond the allowance |
|---|---|---|
| Members (admins and members; reporters are free) | workspace invites, FlowQA project invites that bring someone new in | refused |
| Projects per product | Snag project create, FlowQA project create in an existing workspace | refused |
| Snags a month | capture | refused |
| Schedules | schedule create | refused |
| Cloud test runs a month | cloud execution start (local runs never count) | 1 credit each, charged when the run completes |
| Explorations a month, AI-generated tests a month | exploration start | 10 credits per accepted test (2 with the customer's own model, phase 4b) |
A refused action answers 402 with code plan_limit and details { product, limit, used, cap, plan }; the message names the plan and says where to upgrade.
Credits are charged only for accepted output (a generated test that replayed green twice and failed the break-it check), never for attempts.
FlowQA Free also keeps at most 100 tests across the workspace's projects (tests); saving, importing or creating a project with more answers 402 plan_limit with limit: tests.
Lifetime plans
Every workspace that existed when billing launched holds Team on both products for life (WorkspaceEntitlement.lifetimePlan, written by the migration).
A lifetime plan is a floor: a trial, a past-due grace or an ended subscription falls back to it, never to Free, and a row written below it still reads as the floor.
Operators grant one with lifetime: true on the grant route.
Bring your own model
A workspace can connect its own Anthropic or OpenAI key (Settings, Billing, "Your own model"; PUT /v1/workspaces/:id/billing/model-keys/:provider).
The API checks the key with the provider before storing it, encrypted with the FlowQA input cipher and scoped to the workspace, and only ever shows its last four characters.
With a key connected:
- the model proxy sends the strong-model calls to the customer's account (Anthropic first, OpenAI translated to and from the Anthropic message shape), and Jev stays ours in every case;
- cloud workers get
model_via_api: trueon the job and send their model calls through the API, so the key never reaches a worker; - every call is still on the ledger, with
provider: customerand zero cost to us; - an accepted generated test costs 2 credits instead of 10, Free's AI allowance is 50 tests a month instead of 10, and an exploration run on the desktop with the customer's model is not metered at all;
- a refused key pauses the run with the provider's words and a pointer to Settings; it never falls back to our key.
OPENAI_STRONG_MODEL picks the OpenAI model (default gpt-4.1).
Sign in with ChatGPT (spec 4.3) needs OpenAI's approval of the app first and is not built.
Credits
WorkspaceBilling.creditBalance is the balance; every movement is a CreditTransaction with balanceAfter, so the ledger audits itself.
Packs: 1,000 for 10 USD, 5,000 for 45, 20,000 for 160.
Charges never take the balance below zero: a cloud run that outruns the credits is still recorded, with what was paid on its rows.
The provider
Dodo Payments is the merchant of record: it handles tax, invoices, cards, dunning emails and the customer portal. We create checkout sessions and read webhooks; plan state moves only through webhooks (or a trial, which needs no card).
Set up, once per environment (test and live are separate in Dodo):
- Create the products in the Dodo dashboard: one per plan and interval (
snag.team.monthly,flowqa.starter.yearly,suite.business.monthly, ...), one per credit pack (credits_1000,credits_5000,credits_20000), and one monthly subscription per dedicated-agent pack (agents_1,agents_2,agents_3,agents_10). Prices are inplans.ts; yearly is ten months. - Put their ids in
DODO_PRODUCTSas JSON keyed by those names. - Create a webhook for
https://<api>/v1/billing/webhooks/dodowith the subscription and payment events, and put its signing secret inDODO_PAYMENTS_WEBHOOK_KEY. - Put the API key in
DODO_PAYMENTS_API_KEYand setDODO_PAYMENTS_ENVIRONMENT(test_modeorlive_mode).
Without the keys, the Billing page shows the plans and the trial, and purchase buttons say billing is not set up.
BILLING_DEFAULT_PLAN is the plan a new workspace starts on, free by default.
A pilot can start everyone on team; the API test environment uses business, since its fixtures are paid-size workspaces, and the billing suite sets Free where it tests the Free limits.
Webhooks are verified with the Standard Webhooks signature (webhook-id, webhook-timestamp, webhook-signature), deduplicated by delivery id (BillingEvent), and applied from the session's metadata (workspace_id, scope, plan, interval, or credits_pack), with the product map as the fallback.
Dedicated agents
The add-on of spec 5.2: browser workers reserved for one workspace, bought as a monthly subscription (POST /v1/workspaces/:id/billing/agents/checkout with a pack; a held pack changes in place).
Business includes one agent; the add-on stacks on it (WorkspaceEntitlement.addOns.dedicated_agents, read into the dedicated_agents allowance).
A workspace with agents gets priority 1 on its cloud runs, which shared workers take before anything else.
The agents themselves are workers we start with FLOWQA_WORKER_WORKSPACE=<workspace id> (see deploy/flowqa-worker/README.md): they claim that workspace's runs, explorations and repairs only, and GET /v1/qa/cloud/status?workspace_id= reports them as dedicated to that workspace's members.
Bringing an agent online after a purchase is an operator step; the Billing page says so and shows the agents once they report in.
Retention
Retention follows the plan in force: Snag keeps a snag's media for retention_days (30 on Free, a year on Team and Business) once the snag is closed or rejected, and FlowQA keeps a run's screenshots and traces for artifacts_days (30, 30, 90 or 365) after the run finished.
Open snags keep their media whatever their age, and results, snags and runs never go; only the files do.
A null allowance (Enterprise, a custom grant) keeps everything.
RetentionService sweeps once an hour, up to 500 rows of each kind per workspace per pass, queuing the files on the same durable cleanup queue project deletion uses, so an object-store outage delays a deletion rather than losing track of it.
A purged attachment keeps its row with purgedAt; the snag lists it as media_purged and its download answers 404.
A purged run carries artifacts_purged_at; the workbench says the frames were removed rather than showing an empty gallery.
Operators
A platform operator (User.superAdmin) can set a complimentary plan with POST /v1/workspaces/:id/billing/grant ({ product, plan, allowances?, note? }): our own workspaces, pilots, Enterprise deals invoiced outside the provider.
Do this for TPH's own workspaces right after the migration, since every workspace starts on Free.